From 1a524c90543805d6a395e215c44da92c77a7e963 Mon Sep 17 00:00:00 2001 From: AEGIS Date: Sat, 11 Apr 2026 09:31:37 -0500 Subject: [PATCH] fix(tracer,metrics): propagate flush() through to exporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracer.flush() and MetricsCollector.flush() only drained their own local buffer into options.export.export(...) — they never called options.export.flush() to force the exporter to POST. On Cloudflare Workers the default StackbiltCloudExporter buffers across requests and only POSTs at a 100-item / 50KB threshold; low-volume isolates get evicted long before that trips, so buffered spans die silently. Option B from #7: make the flush chain transitive. Add an optional flush?() to SpanExporter and MetricsExporter interfaces, and call it after export() in both collectors. Exporters without flush() are unaffected (backward-compatible). Verified end-to-end in tarotscript-worker dogfood instance: after applying the same pattern to that worker's consumer-side shim, traces landed on every request instead of only at the 100-span batch boundary. Closes #7. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/metrics.ts | 30 ++++++++++++++++++++++++++---- src/tracing.ts | 31 ++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/metrics.ts b/src/metrics.ts index c74cbb9..222c7fd 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -18,6 +18,18 @@ export interface MetricsCollectorOptions { export interface MetricsExporter { export(metrics: MetricPoint[]): Promise; + /** + * Optional: exporters that buffer internally can expose a flush() hook to + * be drained by MetricsCollector.flush(). Critical for Cloudflare Workers + * where the isolate dies before a size-threshold auto-flush would fire. + */ + flush?(): Promise; +} + +function hasExporterFlush( + exporter: MetricsExporter | undefined +): exporter is MetricsExporter & { flush: () => Promise } { + return typeof exporter?.flush === 'function'; } export class CloudflareAnalyticsExporter implements MetricsExporter { @@ -330,17 +342,27 @@ export class MetricsCollector { } /** - * Flush metrics to exporter + * Flush metrics to exporter. + * + * Drains the collector's local buffer AND calls the exporter's own flush() + * if it exposes one. See worker-observability#7 for why the second call + * matters on Cloudflare Workers. */ async flush(): Promise { - if (this.buffer.length === 0) return; + const exporter = this.options.export; + if (this.buffer.length === 0 && !hasExporterFlush(exporter)) return; const metrics = [...this.buffer]; this.buffer = []; - if (this.options.export) { + if (exporter) { try { - await this.options.export.export(metrics); + if (metrics.length > 0) { + await exporter.export(metrics); + } + if (hasExporterFlush(exporter)) { + await exporter.flush(); + } } catch (error) { console.error('Failed to export metrics:', error); // Re-add metrics to buffer on failure diff --git a/src/tracing.ts b/src/tracing.ts index b31bc47..2551d60 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -25,6 +25,18 @@ export interface TracerOptions { export interface SpanExporter { export(spans: TraceSpan[]): Promise; + /** + * Optional: exporters that buffer internally can expose a flush() hook to + * be drained by Tracer.flush(). Critical for Cloudflare Workers where the + * isolate dies before a size-threshold auto-flush would fire. + */ + flush?(): Promise; +} + +function hasExporterFlush( + exporter: SpanExporter | undefined +): exporter is SpanExporter & { flush: () => Promise } { + return typeof exporter?.flush === 'function'; } export interface SpanContext { @@ -244,17 +256,30 @@ export class Tracer { } /** - * Flush buffered spans + * Flush buffered spans. + * + * Drains the tracer's local buffer into the configured exporter AND then + * calls the exporter's own flush() if it exposes one. This matters on + * Cloudflare Workers, where the default exporter (StackbiltCloudExporter) + * buffers across requests and only POSTs at a size/byte threshold. In a + * low-volume isolate that threshold is rarely reached before eviction, so + * without the second flush call the spans die with the isolate. See + * worker-observability#7. */ async flush(): Promise { - if (this.buffer.length === 0) return; + if (this.buffer.length === 0 && !hasExporterFlush(this.options.export)) return; const spans = [...this.buffer]; this.buffer = []; if (this.options.export) { try { - await this.options.export.export(spans); + if (spans.length > 0) { + await this.options.export.export(spans); + } + if (hasExporterFlush(this.options.export)) { + await this.options.export.flush(); + } } catch (error) { console.error('Failed to export traces:', error); // Re-add spans to buffer on failure