open-telemetry / open-telemetry/opentelemetry-php

Asynchronous instrument with no observations is exported as a metric with zero data points

Open
#2,052 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
PHP
Stars
912
Forks
232
Avg merge
7d 16h
Merged PRs (30d)
4

Description

Description

When an asynchronous instrument's callback records no observations during a collection cycle, the SDK still emits a metric envelope for it, carrying an empty data point list. Serialized to OTLP JSON it looks like this:

{"name":"never.observed","gauge":{}}

Consumers reject this. Prometheus's OTLP write handler treats it as an append error, returns HTTP 500 for the entire request and runs app.Rollback() in a defer, so every healthy metric batched alongside the empty one is discarded too:

level=ERROR source=write_otlp_handler.go:194 msg="Error appending remote write" component=web
  err="empty data points. never.observed is dropped"

Because it is a whole-batch rejection rather than a per-metric one, a single instrument that happens to observe nothing takes down an entire export. In our case this ran at ~100-130 rejections per 10 minutes for over 30 hours across two production regions before it was traced, and the collateral (unrelated, fully populated metrics riding the same batch) was what actually broke: it took a backup scheduler's telemetry off the air and blinded the alerting built on it, while the service itself was healthy the whole time.

It is easy to hit unintentionally. Our trigger was two gauges that partition one list — one observes entries matching a predicate, the other observes the rest — so the ordinary steady state where the list is empty guarantees both observe nothing.

Steps to reproduce

<?php
require __DIR__ . '/vendor/autoload.php';

use OpenTelemetry\Contrib\Otlp\ContentTypes;
use OpenTelemetry\Contrib\Otlp\MetricExporter;
use OpenTelemetry\SDK\Common\Export\TransportInterface;
use OpenTelemetry\SDK\Common\Future\CancellationInterface;
use OpenTelemetry\SDK\Common\Future\CompletedFuture;
use OpenTelemetry\SDK\Common\Future\FutureInterface;
use OpenTelemetry\SDK\Metrics\Data\Temporality;
use OpenTelemetry\SDK\Metrics\MeterProvider;
use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader;

$transport = new class implements TransportInterface {
    public function contentType(): string { return ContentTypes::JSON; }
    public function send(string $payload, ?CancellationInterface $c = null): FutureInterface {
        echo $payload, "\n";
        return new CompletedFuture(null);
    }
    public function shutdown(?CancellationInterface $c = null): bool { return true; }
    public function forceFlush(?CancellationInterface $c = null): bool { return true; }
};

$reader = new ExportingReader(new MetricExporter($transport, Temporality::CUMULATIVE));
$meter = MeterProvider::builder()->addReader($reader)->build()->getMeter('repro');

$meter->createObservableGauge('never.observed')->observe(function ($observer): void {
    // records nothing this collection cycle
});
$meter->createCounter('always.counted')->add(1);

$reader->collect();

Expected

never.observed is not present in the exported payload — an instrument with no measurements in the cycle has nothing to report.

Actual

It is exported with an empty data point list, alongside the populated counter:

{
  "resourceMetrics": [{
    "resource": {},
    "scopeMetrics": [{
      "scope": {"name": "repro"},
      "metrics": [
        {"name": "never.observed", "gauge": {}},
        {"name": "always.counted", "sum": {
          "dataPoints": [{"startTimeUnixNano": "...", "timeUnixNano": "...", "asInt": "1"}],
          "aggregationTemporality": 2, "isMonotonic": true
        }}
      ]
    }]
  }]
}

Environment

  • open-telemetry/sdk 1.15.0
  • open-telemetry/exporter-otlp 1.4.0
  • open-telemetry/api 1.10.0
  • PHP 8.5.10

Notes

Is emitting the empty envelope intended? If a metric with no data points is legitimate output, then arguably it is Prometheus that is wrong to reject it (tracked at prometheus/prometheus#19338, where it is a regression — 3.5.0 accepted the rest of the batch, 3.13+ rejects all of it). But suppressing it in the SDK looks like the more robust fix, since it costs nothing to omit a metric that carries no measurements, and it does not depend on every downstream consumer being lenient.

Happy to open a PR if you can point me at the layer you would prefer it handled — the aggregation/toData path or the reader.

We are working around it downstream for now with a MetricExporterInterface decorator that drops metrics whose data point list is empty before serialization. MetricExporter being final means a decorator is the only interception point available from outside the SDK.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the supplied PHP reproduction and trace collection through ExportingReader and MetricExporter, then compare the aggregation/toData path mentioned in the issue. Identify where an asynchronous instrument with no observations becomes an exported metric, and add focused coverage showing that the empty instrument is absent while always.counted remains. Done means the reproduced payload omits never.observed without dropping populated metrics.

Written by the indexing model from the issue text.

Assessment

Tech stack
php
Domain
observability-sre
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.