Skip to content

Commit b0ecf59

Browse files
committed
feat(webapp): add ingest.* metrics for the telemetry ingestion pipeline
Instruments the OTLP ingest path, the transform worker pool, and the shared flush scheduler with OpenTelemetry metrics under a single ingest.* prefix: request and byte throughput with per-signal produced counts, worker task vs compute durations plus queue depth and worker health, and flush batch size, duration, queue depth and dropped-batch counters. Reuses the existing MeterProvider and is gated by the existing INTERNAL_OTEL_METRIC_EXPORTER_ENABLED, so every instrument is a no-op when metrics are disabled. Recording happens in bulk once per request or flush (never per span) with pull-based gauges, so it stays off the hot path.
1 parent a4ce5fe commit b0ecf59

11 files changed

Lines changed: 649 additions & 72 deletions

apps/webapp/app/v3/dynamicFlushScheduler.server.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Logger } from "@trigger.dev/core/logger";
22
import { tryCatch } from "@trigger.dev/core/utils";
3+
import { getMeter, type Counter, type Histogram, type Meter } from "@internal/tracing";
34
import { nanoid } from "nanoid";
45
import pLimit from "p-limit";
56
import { signalsEmitter } from "~/services/signals.server";
@@ -16,6 +17,11 @@ export type DynamicFlushSchedulerConfig<T> = {
1617
loadSheddingThreshold?: number; // Number of items that triggers load shedding
1718
loadSheddingEnabled?: boolean;
1819
isDroppableEvent?: (item: T) => boolean; // Function to determine if an event can be dropped
20+
// Self-observability. `name` is the low-cardinality `scheduler` label that separates the
21+
// task_events / llm_metrics / otlp_metrics instances in the same process. `meter` defaults to
22+
// the global provider; inject one in tests. Instruments are no-op unless metrics are enabled.
23+
meter?: Meter;
24+
name?: string;
1925
};
2026

2127
export class DynamicFlushScheduler<T> {
@@ -54,7 +60,21 @@ export class DynamicFlushScheduler<T> {
5460

5561
private readonly logger: Logger = new Logger("EventRepo.DynamicFlushScheduler", "info");
5662

63+
// Pre-allocated attribute objects (closed label sets) so the hot flush path never allocates.
64+
private readonly _metricAttrs: { scheduler: string };
65+
private readonly _batchOkAttrs: { scheduler: string; outcome: string };
66+
private readonly _batchFailedAttrs: { scheduler: string; outcome: string };
67+
private _batchesCounter?: Counter;
68+
private _itemsCounter?: Counter;
69+
private _flushDurationHistogram?: Histogram;
70+
private _batchSizeHistogram?: Histogram;
71+
private _droppedEventsCounter?: Counter;
72+
5773
constructor(config: DynamicFlushSchedulerConfig<T>) {
74+
const schedulerName = config.name ?? "unknown";
75+
this._metricAttrs = { scheduler: schedulerName };
76+
this._batchOkAttrs = { scheduler: schedulerName, outcome: "ok" };
77+
this._batchFailedAttrs = { scheduler: schedulerName, outcome: "failed" };
5878
this.batchQueue = [];
5979
this.currentBatch = [];
6080
this.BATCH_SIZE = config.batchSize;
@@ -80,6 +100,54 @@ export class DynamicFlushScheduler<T> {
80100
this.startFlushTimer();
81101
this.startMetricsReporter();
82102
this.setupShutdownHandlers();
103+
this.#setupOtelMetrics(config.meter, schedulerName);
104+
}
105+
106+
#setupOtelMetrics(meterOverride: Meter | undefined, name: string): void {
107+
const meter = meterOverride ?? getMeter("ingest-flush");
108+
109+
this._batchesCounter = meter.createCounter("ingest.flush.batches", {
110+
description: "Batches flushed to the sink, by outcome",
111+
unit: "batches",
112+
});
113+
this._itemsCounter = meter.createCounter("ingest.flush.items", {
114+
description: "Items successfully flushed to the sink",
115+
unit: "items",
116+
});
117+
this._flushDurationHistogram = meter.createHistogram("ingest.flush.duration", {
118+
description: "Wall-clock duration of a single batch flush",
119+
unit: "ms",
120+
});
121+
this._batchSizeHistogram = meter.createHistogram("ingest.flush.batch_size", {
122+
description: "Number of items in a flushed batch",
123+
unit: "items",
124+
});
125+
this._droppedEventsCounter = meter.createCounter("ingest.flush.dropped_events", {
126+
description: "Events dropped by load shedding before they reached the sink",
127+
unit: "events",
128+
});
129+
130+
// Pull-based gauges: read at export time only, so they add zero hot-path cost.
131+
const queueDepthGauge = meter.createObservableGauge("ingest.flush.queue_depth", {
132+
description: "Items queued and awaiting flush",
133+
unit: "items",
134+
});
135+
const concurrencyGauge = meter.createObservableGauge("ingest.flush.concurrency", {
136+
description: "Current concurrent-flush limit",
137+
unit: "flushes",
138+
});
139+
const loadSheddingGauge = meter.createObservableGauge("ingest.flush.load_shedding", {
140+
description: "1 while actively shedding load, otherwise 0",
141+
});
142+
143+
meter.addBatchObservableCallback(
144+
(result) => {
145+
result.observe(queueDepthGauge, this.totalQueuedItems, this._metricAttrs);
146+
result.observe(concurrencyGauge, this.limiter.concurrency, this._metricAttrs);
147+
result.observe(loadSheddingGauge, this.isLoadShedding ? 1 : 0, this._metricAttrs);
148+
},
149+
[queueDepthGauge, concurrencyGauge, loadSheddingGauge]
150+
);
83151
}
84152

85153
addToBatch(items: T[]): void {
@@ -92,6 +160,7 @@ export class DynamicFlushScheduler<T> {
92160

93161
if (dropped.length > 0) {
94162
this.metrics.droppedEvents += dropped.length;
163+
this._droppedEventsCounter?.add(dropped.length, this._metricAttrs);
95164

96165
// Track dropped events by kind if possible
97166
dropped.forEach((item) => {
@@ -213,6 +282,11 @@ export class DynamicFlushScheduler<T> {
213282
self.metrics.flushedBatches++;
214283
self.metrics.totalItemsFlushed += itemCount;
215284

285+
self._flushDurationHistogram?.record(duration, self._metricAttrs);
286+
self._batchSizeHistogram?.record(itemCount, self._metricAttrs);
287+
self._itemsCounter?.add(itemCount, self._metricAttrs);
288+
self._batchesCounter?.add(1, self._batchOkAttrs);
289+
216290
self.logger.debug("Batch flushed successfully", {
217291
flushId,
218292
itemCount,
@@ -253,6 +327,7 @@ export class DynamicFlushScheduler<T> {
253327
this.logger.error("Error flushing batch", {
254328
error: flushError,
255329
});
330+
this._batchesCounter?.add(1, this._batchFailedAttrs);
256331
}
257332
})
258333
);

apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import type {
88
TaskEventV1Input,
99
TaskEventV2Input,
1010
} from "@internal/clickhouse";
11-
import type { Attributes, Tracer } from "@internal/tracing";
12-
import { startSpan, trace } from "@internal/tracing";
11+
import type { Attributes, Counter, Meter, Tracer } from "@internal/tracing";
12+
import { getMeter, startSpan, trace } from "@internal/tracing";
1313

1414
import { createJsonErrorObject } from "@trigger.dev/core/v3/errors";
1515
import { serializeTraceparent } from "@trigger.dev/core/v3/isomorphic";
@@ -104,6 +104,8 @@ export type ClickhouseEventRepositoryConfig = {
104104
otlpMetricsBatchSize?: number;
105105
otlpMetricsFlushInterval?: number;
106106
otlpMetricsMaxConcurrency?: number;
107+
/** Inject a meter for self-observability; defaults to the global provider. */
108+
meter?: Meter;
107109
};
108110

109111
/**
@@ -125,14 +127,22 @@ export class ClickhouseEventRepository implements IEventRepository {
125127
* track the drop count for observability.
126128
*/
127129
private _permanentlyDroppedBatches = 0;
130+
private readonly _droppedBatchesCounter: Counter;
128131

129132
constructor(config: ClickhouseEventRepositoryConfig) {
130133
this._clickhouse = config.clickhouse;
131134
this._config = config;
132135
this._tracer = config.tracer ?? trace.getTracer("clickhouseEventRepo", "0.0.1");
133136
this._version = config.version ?? "v1";
134137

138+
const meter = config.meter ?? getMeter("ingest-flush");
139+
this._droppedBatchesCounter = meter.createCounter("ingest.flush.batches_dropped", {
140+
description: "Batches permanently dropped after an unrecoverable ClickHouse JSON parse error",
141+
unit: "batches",
142+
});
143+
135144
this._flushScheduler = new DynamicFlushScheduler({
145+
name: `task_events_${this._version}`,
136146
batchSize: config.batchSize ?? 1000,
137147
flushInterval: config.flushInterval ?? 1000,
138148
callback: this.#flushBatch.bind(this),
@@ -149,6 +159,7 @@ export class ClickhouseEventRepository implements IEventRepository {
149159
});
150160

151161
this._llmMetricsFlushScheduler = new DynamicFlushScheduler({
162+
name: "llm_metrics",
152163
batchSize: config.llmMetricsBatchSize ?? 5000,
153164
flushInterval: config.llmMetricsFlushInterval ?? 2000,
154165
callback: this.#flushLlmMetricsBatch.bind(this),
@@ -160,6 +171,7 @@ export class ClickhouseEventRepository implements IEventRepository {
160171
});
161172

162173
this._otlpMetricsFlushScheduler = new DynamicFlushScheduler({
174+
name: "otlp_metrics",
163175
batchSize: config.otlpMetricsBatchSize ?? 10000,
164176
flushInterval: config.otlpMetricsFlushInterval ?? 1000,
165177
callback: this.#flushOtelMetricsBatch.bind(this),
@@ -359,6 +371,7 @@ export class ClickhouseEventRepository implements IEventRepository {
359371
// exactly the retry storm this wrapper is designed to avoid.
360372
if (fieldsSanitized === 0) {
361373
this._permanentlyDroppedBatches += 1;
374+
this._droppedBatchesCounter.add(1, { table: contextLabel });
362375
logger.error(
363376
"Dropped batch — ClickHouse JSON parse error but sanitizer found nothing to fix",
364377
{
@@ -390,6 +403,7 @@ export class ClickhouseEventRepository implements IEventRepository {
390403
if (!isClickHouseJsonParseError(retryError)) throw retryError;
391404

392405
this._permanentlyDroppedBatches += 1;
406+
this._droppedBatchesCounter.add(1, { table: contextLabel });
393407
const retryMessage =
394408
typeof retryError === "object" && retryError !== null && "message" in retryError
395409
? String((retryError as { message?: unknown }).message ?? "")

apps/webapp/app/v3/eventRepository/eventRepository.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export class EventRepository implements IEventRepository {
101101
private readonly _config: EventRepoConfig
102102
) {
103103
this._flushScheduler = new DynamicFlushScheduler({
104+
name: "postgres_events",
104105
batchSize: _config.batchSize,
105106
flushInterval: _config.batchInterval,
106107
callback: this.#flushBatch.bind(this),

0 commit comments

Comments
 (0)