Streaming metrics (Polyglot)
Overview¶
This example pairs two ways of turning the same Polyglot streaming events into
metrics, run against one shared EventDispatcher:
- a hand-rolled
StreamMetricsCollector(Cognesy\Metrics\Collectors\MetricsCollector) that listens for streaming-specific events and builds derived metrics no runtime package knows about: time to first chunk, chunk count, tokens/second. - the canonical Telemetry path — a
Cognesy\Telemetry\Application\Telemetryhub wired toCognesy\Polyglot\Telemetry\PolyglotTelemetryProjectorthroughRuntimeEventBridge— which turns the very sameInferenceStarted/InferenceAttemptSucceeded/InferenceUsageReported/InferenceCompletedevents into spans, logs, and the cataloginference.client.*metrics (token usage histograms, attempt/operation counters and timers) automatically, with zero extra collector code.
The output below is grouped so the two halves stay visibly distinct: metrics from the hand-rolled collector, and metrics + spans emitted by the Telemetry projector for free from the same run.
Example¶
<?php
require 'examples/boot.php';
use Cognesy\Events\Dispatchers\EventDispatcher;
use Cognesy\Messages\Messages;
use Cognesy\Metrics\Collectors\MetricsCollector;
use Cognesy\Metrics\Contracts\CanExportMetrics;
use Cognesy\Metrics\Data\Metric;
use Cognesy\Metrics\Exporters\CallbackExporter;
use Cognesy\Metrics\Metrics;
use Cognesy\Polyglot\Inference\Events\InferenceCompleted;
use Cognesy\Polyglot\Inference\Events\PartialInferenceDeltaCreated;
use Cognesy\Polyglot\Inference\Events\StreamFirstChunkReceived;
use Cognesy\Polyglot\Inference\Inference;
use Cognesy\Polyglot\Inference\InferenceRuntime;
use Cognesy\Polyglot\Inference\Config\LLMConfig;
use Cognesy\Polyglot\Telemetry\PolyglotTelemetryProjector;
use Cognesy\Telemetry\Application\Projector\RuntimeEventBridge;
use Cognesy\Telemetry\Application\Registry\TraceRegistry;
use Cognesy\Telemetry\Application\Telemetry;
use Cognesy\Telemetry\Domain\Contract\CanExportObservations;
use Cognesy\Telemetry\Domain\Observation\Observation;
final class StreamMetricsCollector extends MetricsCollector
{
private int $chunkCount = 0;
protected function listeners(): array {
return [
StreamFirstChunkReceived::class => 'onFirstChunk',
PartialInferenceDeltaCreated::class => 'onChunk',
InferenceCompleted::class => 'onCompleted',
];
}
public function onFirstChunk(StreamFirstChunkReceived $event): void {
$this->timer('llm.stream.ttfc_ms', $event->timeToFirstChunkMs, [
'model' => $this->modelTag($event->model),
]);
}
public function onChunk(PartialInferenceDeltaCreated $event): void {
$this->chunkCount += 1;
}
public function onCompleted(InferenceCompleted $event): void {
$durationMs = $event->data['durationMs'] ?? 1;
$durationSeconds = max(0.001, $durationMs / 1000);
$outputTokens = $event->data['outputTokens'] ?? 0;
$tokensPerSecond = $outputTokens / $durationSeconds;
$this->timer('llm.stream.duration_ms', $durationMs);
$this->gauge('llm.stream.chunk_count', (float) $this->chunkCount);
$this->gauge('llm.stream.output_tokens', (float) $outputTokens);
$this->gauge('llm.stream.output_tokens_per_second', $tokensPerSecond);
$this->chunkCount = 0;
}
private function modelTag(?string $model): string {
if ($model !== null && $model !== '') {
return $model;
}
return 'default';
}
}
/**
* In-memory sink for the Telemetry side: no network, no credentials — just
* keeps whatever spans/logs and metrics the projector hands it, so the
* example can print them. A real app would pass an OTel/Langfuse/Logfire
* exporter here instead; the projector wiring is identical either way.
*/
final class InMemoryTelemetryExporter implements CanExportObservations, CanExportMetrics
{
/** @var list<Observation> */
private array $observations = [];
/** @var list<Metric> */
private array $metrics = [];
public function exportObservation(Observation $observation): void {
$this->observations[] = $observation;
}
/** @param iterable<Metric> $metrics */
public function export(iterable $metrics): void {
foreach ($metrics as $metric) {
$this->metrics[] = $metric;
}
}
/** @return list<Observation> */
public function observations(): array {
return $this->observations;
}
/** @return list<Metric> */
public function metrics(): array {
return $this->metrics;
}
}
$events = new EventDispatcher();
$metrics = new Metrics($events);
$metrics->collect(new StreamMetricsCollector());
// Telemetry side: same $events dispatcher, canonical projector. This turns
// InferenceStarted/InferenceAttemptSucceeded/InferenceUsageReported/InferenceCompleted
// into spans, logs and inference.client.* metrics with no extra collector code.
$telemetryExporter = new InMemoryTelemetryExporter();
$telemetry = new Telemetry(new TraceRegistry(), $telemetryExporter);
(new RuntimeEventBridge(new PolyglotTelemetryProjector($telemetry)))->attachTo($events);
$prompt = 'In one sentence, explain why streaming responses help UX.';
$runtime = InferenceRuntime::fromConfig(config: LLMConfig::fromPreset('openai'), events: $events);
$stream = Inference::fromRuntime($runtime)
->withMessages(Messages::fromString($prompt))
->withOptions(['max_tokens' => 64])
->withStreaming()
->stream()
->deltas();
echo "USER: {$prompt}\n";
echo "ASSISTANT: ";
foreach ($stream as $delta) {
echo $delta->contentDelta;
}
echo "\n\n";
// Metrics buffered by Telemetry::metric() only reach an exporter that implements
// CanExportMetrics, and only once flush() runs — call it after the stream is
// fully consumed, same as span/log completion depends on the terminal events.
$telemetry->flush();
$exportedMetrics = [];
$metrics->exportTo(new CallbackExporter(function (iterable $m) use (&$exportedMetrics): void {
foreach ($m as $metric) {
$exportedMetrics[] = $metric;
}
}));
$metrics->export();
echo "-- StreamMetricsCollector (hand-rolled, derived) --\n";
foreach (aggregateMetrics($exportedMetrics) as $aggregate) {
printf(
"[%s] %s%s = %.2f\n",
$aggregate['type'],
$aggregate['name'],
formatTags($aggregate['tags']),
aggregatedValue($aggregate),
);
}
echo "\n";
echo "-- PolyglotTelemetryProjector metrics (catalog inference.client.*, emitted for free) --\n";
foreach ($telemetryExporter->metrics() as $metric) {
printf("[%s] %s = %.2f\n", $metric->type(), $metric->name(), $metric->value());
}
echo "\n";
echo "-- PolyglotTelemetryProjector spans/logs --\n";
foreach ($telemetryExporter->observations() as $observation) {
printf("[%s] %s (%s)\n", $observation->kind()->value, $observation->name(), $observation->status()->value);
}
assert(count($exportedMetrics) > 0, 'Expected non-empty hand-rolled metrics collection');
assert(count($telemetryExporter->metrics()) > 0, 'Expected Telemetry to have exported inference.client.* metrics');
assert(count($telemetryExporter->observations()) > 0, 'Expected Telemetry to have produced at least one span/log');
function formatTags(array $tags): string {
if ($tags === []) {
return '';
}
$keys = array_keys($tags);
$values = array_values($tags);
$tagList = array_map(
static fn (string $key, mixed $value): string => "{$key}=\"{$value}\"",
$keys,
$values,
);
return ' {' . implode(', ', $tagList) . '}';
}
/**
* @param iterable<Metric> $metrics
* @return array<int, array{type: string, name: string, tags: array, count: int, sum: float, last: float}>
*/
function aggregateMetrics(iterable $metrics): array {
$aggregates = [];
foreach ($metrics as $metric) {
$key = $metric->type() . '|' . $metric->name() . '|' . $metric->tags()->toKey();
if (!array_key_exists($key, $aggregates)) {
$aggregates[$key] = [
'type' => $metric->type(),
'name' => $metric->name(),
'tags' => $metric->tags()->toArray(),
'count' => 0,
'sum' => 0.0,
'last' => 0.0,
];
}
$aggregates[$key]['count'] += 1;
$aggregates[$key]['sum'] += $metric->value();
$aggregates[$key]['last'] = $metric->value();
}
return array_values($aggregates);
}
/**
* @param array{type: string, count: int, sum: float, last: float} $aggregate
*/
function aggregatedValue(array $aggregate): float {
return match ($aggregate['type']) {
'counter' => $aggregate['sum'],
'timer', 'histogram' => $aggregate['sum'] / max(1, $aggregate['count']),
default => $aggregate['last'],
};
}
?>