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