Skip to content

Commit 507bdf5

Browse files
committed
feat(webapp): parallelize OTLP ingest transform via a worker pool (flag-gated)
Behind OTEL_TRANSFORM_WORKER_POOL_ENABLED (default off), OTLP protobuf decode + convert + enrich (incl. LLM pricing match/calculateCost) run in a worker_threads pool instead of the request event loop. The main thread stays the single DB reader (broadcasts compiled model rows to workers via ModelPricingRegistry.loadFromModels) and keeps the existing consolidated insertMany path, so ClickHouse insert cadence and part count are unchanged. Adds: otlpTransformWorker (worker entry), otlpWorkerPool (hand-rolled worker_threads pool with per-worker single-flight, pricing broadcast, respawn-on-crash), exporter exportXRaw dispatch methods + flag, route wiring, a bundled build:otlpworker esbuild step, and ModelPricingRegistry.loadFromModels/toSerializable (prisma-optional) + tests. Local e2e on an isolated stack: flag-on sustained ~2.6x throughput (997 -> 2612 spans/s) and kept the main-thread health check responsive (0.5-2.5s vs 4.6-10.4s) at identical load; enriched events with registry-sourced cost verified in ClickHouse via the worker path.
1 parent 6066617 commit 507bdf5

10 files changed

Lines changed: 456 additions & 8 deletions

File tree

apps/webapp/app/routes/otel.v1.logs.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { ExportLogsServiceRequest, ExportLogsServiceResponse } from "@trigger.dev/otlp-importer";
4-
import { otlpExporter } from "~/v3/otlpExporter.server";
4+
import { otlpExporter, otlpTransformWorkerPoolEnabled } from "~/v3/otlpExporter.server";
55

66
export async function action({ request }: ActionFunctionArgs) {
77
try {
@@ -17,6 +17,15 @@ export async function action({ request }: ActionFunctionArgs) {
1717
} else if (contentType.startsWith("application/x-protobuf")) {
1818
const buffer = await request.arrayBuffer();
1919

20+
if (otlpTransformWorkerPoolEnabled) {
21+
await exporter.exportLogsRaw(new Uint8Array(buffer));
22+
23+
return new Response(
24+
ExportLogsServiceResponse.encode(ExportLogsServiceResponse.create()).finish(),
25+
{ status: 200 }
26+
);
27+
}
28+
2029
const exportRequest = ExportLogsServiceRequest.decode(new Uint8Array(buffer));
2130

2231
const exportResponse = await exporter.exportLogs(exportRequest);

apps/webapp/app/routes/otel.v1.metrics.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
ExportMetricsServiceRequest,
55
ExportMetricsServiceResponse,
66
} from "@trigger.dev/otlp-importer";
7-
import { otlpExporter } from "~/v3/otlpExporter.server";
7+
import { otlpExporter, otlpTransformWorkerPoolEnabled } from "~/v3/otlpExporter.server";
88

99
export async function action({ request }: ActionFunctionArgs) {
1010
try {
@@ -21,6 +21,15 @@ export async function action({ request }: ActionFunctionArgs) {
2121
const exporter = await otlpExporter;
2222
const buffer = await request.arrayBuffer();
2323

24+
if (otlpTransformWorkerPoolEnabled) {
25+
await exporter.exportMetricsRaw(new Uint8Array(buffer));
26+
27+
return new Response(
28+
ExportMetricsServiceResponse.encode(ExportMetricsServiceResponse.create()).finish(),
29+
{ status: 200 }
30+
);
31+
}
32+
2433
const exportRequest = ExportMetricsServiceRequest.decode(new Uint8Array(buffer));
2534

2635
const exportResponse = await exporter.exportMetrics(exportRequest);

apps/webapp/app/routes/otel.v1.traces.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { ExportTraceServiceRequest, ExportTraceServiceResponse } from "@trigger.dev/otlp-importer";
4-
import { otlpExporter } from "~/v3/otlpExporter.server";
4+
import { otlpExporter, otlpTransformWorkerPoolEnabled } from "~/v3/otlpExporter.server";
55

66
export async function action({ request }: ActionFunctionArgs) {
77
try {
@@ -17,6 +17,15 @@ export async function action({ request }: ActionFunctionArgs) {
1717
} else if (contentType.startsWith("application/x-protobuf")) {
1818
const buffer = await request.arrayBuffer();
1919

20+
if (otlpTransformWorkerPoolEnabled) {
21+
await exporter.exportTracesRaw(new Uint8Array(buffer));
22+
23+
return new Response(
24+
ExportTraceServiceResponse.encode(ExportTraceServiceResponse.create()).finish(),
25+
{ status: 200 }
26+
);
27+
}
28+
2029
const exportRequest = ExportTraceServiceRequest.decode(new Uint8Array(buffer));
2130

2231
const exportResponse = await exporter.exportTraces(exportRequest);

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

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ import {
3030
isBoolValue,
3131
isStringValue,
3232
} from "./otlpTransform.server";
33+
import os from "node:os";
34+
import { getOtlpWorkerPool } from "./otlpWorkerPool.server";
35+
import { llmPricingRegistry } from "./llmPricingRegistry.server";
36+
37+
// When enabled, decode+convert+enrich run in a worker pool; the main thread keeps the single
38+
// consolidated insert path (batching/part-count unchanged). Off = today's single-thread path.
39+
export const otlpTransformWorkerPoolEnabled =
40+
process.env.OTEL_TRANSFORM_WORKER_POOL_ENABLED === "1";
41+
42+
const OTEL_TRANSFORM_WORKER_POOL_SIZE = process.env.OTEL_TRANSFORM_WORKER_POOL_SIZE
43+
? parseInt(process.env.OTEL_TRANSFORM_WORKER_POOL_SIZE, 10)
44+
: Math.max(1, (os.cpus()?.length ?? 2) - 2);
3345

3446
type OTLPExporterConfig = {
3547
clickhouseFactory: ClickhouseFactory;
@@ -110,10 +122,55 @@ class OTLPExporter {
110122
});
111123
}
112124

125+
async exportTracesRaw(payload: Uint8Array): Promise<void> {
126+
await this.#exportRawEvents("traces", payload);
127+
}
128+
129+
async exportLogsRaw(payload: Uint8Array): Promise<void> {
130+
await this.#exportRawEvents("logs", payload);
131+
}
132+
133+
async exportMetricsRaw(payload: Uint8Array): Promise<void> {
134+
await startSpan(this._tracer, "exportMetricsRaw", async (span) => {
135+
const pool = await this.#pool();
136+
const { rows } = await pool.runTransform("metrics", payload, this.#transformConfig());
137+
span.setAttribute("metric_row_count", rows.length);
138+
if (rows.length > 0) {
139+
await this.#exportMetricRows(rows);
140+
}
141+
});
142+
}
143+
144+
async #exportRawEvents(kind: "traces" | "logs", payload: Uint8Array): Promise<void> {
145+
await startSpan(this._tracer, kind === "traces" ? "exportTracesRaw" : "exportLogsRaw", async (span) => {
146+
const pool = await this.#pool();
147+
const { eventsWithStores } = await pool.runTransform(kind, payload, this.#transformConfig());
148+
const eventCount = await this.#exportEvents(eventsWithStores, true);
149+
span.setAttribute("event_count", eventCount);
150+
});
151+
}
152+
153+
#transformConfig() {
154+
return {
155+
spanAttributeValueLengthLimit: this._spanAttributeValueLengthLimit,
156+
defaultEventStore: env.EVENT_REPOSITORY_DEFAULT_STORE,
157+
};
158+
}
159+
160+
async #pool() {
161+
await waitForLlmPricingReady();
162+
const models =
163+
llmPricingRegistry && llmPricingRegistry.isLoaded ? llmPricingRegistry.toSerializable() : [];
164+
return getOtlpWorkerPool(OTEL_TRANSFORM_WORKER_POOL_SIZE, models);
165+
}
166+
113167
async #exportEvents(
114-
eventsWithStores: { events: Array<CreateEventInput>; taskEventStore: string }[]
168+
eventsWithStores: { events: Array<CreateEventInput>; taskEventStore: string }[],
169+
alreadyEnriched = false
115170
) {
116-
await waitForLlmPricingReady();
171+
if (!alreadyEnriched) {
172+
await waitForLlmPricingReady();
173+
}
117174

118175
// Group by unique event repositories
119176
const routeCache = new Map<string, { key: string; repository: IEventRepository }>();
@@ -150,7 +207,7 @@ class OTLPExporter {
150207
let eventCount = 0;
151208

152209
for (const [repoKey, { repository, events }] of groups) {
153-
const enrichedEvents = enrichCreatableEvents(events);
210+
const enrichedEvents = alreadyEnriched ? events : enrichCreatableEvents(events);
154211

155212
this.#logEventsVerbose(enrichedEvents, `exportEvents ${repoKey}`);
156213

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,48 @@ import type {
1818
CreateEventInput,
1919
} from "./eventRepository/eventRepository.types";
2020

21+
// Filters mirror OTLPExporter's #filterResource* methods, minus the debug logging, so a
22+
// worker can run them without the server logger.
23+
export function filterResourceSpans(resourceSpans: ResourceSpans[]): ResourceSpans[] {
24+
return resourceSpans.filter((resourceSpan) => {
25+
const triggerAttribute = resourceSpan.resource?.attributes.find(
26+
(attribute) => attribute.key === SemanticInternalAttributes.TRIGGER
27+
);
28+
const executionEnvironmentAttribute = resourceSpan.resource?.attributes.find(
29+
(attribute) => attribute.key === SemanticInternalAttributes.EXECUTION_ENVIRONMENT
30+
);
31+
32+
if (!triggerAttribute && !executionEnvironmentAttribute) return true;
33+
34+
const executionEnvironment = isStringValue(executionEnvironmentAttribute?.value)
35+
? executionEnvironmentAttribute.value.stringValue
36+
: undefined;
37+
if (executionEnvironment === "trigger") return true;
38+
39+
return isBoolValue(triggerAttribute?.value) ? triggerAttribute.value.boolValue : false;
40+
});
41+
}
42+
43+
export function filterResourceLogs(resourceLogs: ResourceLogs[]): ResourceLogs[] {
44+
return resourceLogs.filter((resourceLog) => {
45+
const attribute = resourceLog.resource?.attributes.find(
46+
(attribute) => attribute.key === SemanticInternalAttributes.TRIGGER
47+
);
48+
if (!attribute) return false;
49+
return isBoolValue(attribute.value) ? attribute.value.boolValue : false;
50+
});
51+
}
52+
53+
export function filterResourceMetrics(resourceMetrics: ResourceMetrics[]): ResourceMetrics[] {
54+
return resourceMetrics.filter((rm) => {
55+
const triggerAttribute = rm.resource?.attributes.find(
56+
(attribute) => attribute.key === SemanticInternalAttributes.TRIGGER
57+
);
58+
if (!triggerAttribute) return false;
59+
return isBoolValue(triggerAttribute.value) ? triggerAttribute.value.boolValue : false;
60+
});
61+
}
62+
2163
export function convertLogsToCreateableEvents(
2264
resourceLog: ResourceLogs,
2365
spanAttributeValueLengthLimit: number,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { parentPort, workerData } from "node:worker_threads";
2+
import { ModelPricingRegistry } from "@internal/llm-model-catalog";
3+
import type { LlmModelWithPricing } from "@internal/llm-model-catalog";
4+
import {
5+
ExportLogsServiceRequest,
6+
ExportMetricsServiceRequest,
7+
ExportTraceServiceRequest,
8+
} from "@trigger.dev/otlp-importer";
9+
import {
10+
convertLogsToCreateableEvents,
11+
convertMetricsToClickhouseRows,
12+
convertSpansToCreateableEvents,
13+
filterResourceLogs,
14+
filterResourceMetrics,
15+
filterResourceSpans,
16+
} from "./otlpTransform.server";
17+
import { enrichCreatableEvents, setLlmPricingRegistry } from "./utils/enrichCreatableEvents.server";
18+
19+
type TransformTask = {
20+
id: number;
21+
kind: "traces" | "logs" | "metrics";
22+
payload: Uint8Array;
23+
spanAttributeValueLengthLimit: number;
24+
defaultEventStore: string;
25+
};
26+
27+
type PricingUpdate = { type: "pricing"; models: LlmModelWithPricing[] };
28+
29+
// The main thread is the only DB reader; it broadcasts the compiled model rows here.
30+
const registry = new ModelPricingRegistry();
31+
setLlmPricingRegistry(registry);
32+
33+
function applyPricing(models: LlmModelWithPricing[]) {
34+
registry.loadFromModels(models);
35+
}
36+
37+
if (Array.isArray(workerData?.pricingModels)) {
38+
applyPricing(workerData.pricingModels);
39+
}
40+
41+
function runTask(task: TransformTask) {
42+
const bytes = new Uint8Array(task.payload);
43+
44+
if (task.kind === "traces") {
45+
const request = ExportTraceServiceRequest.decode(bytes);
46+
const eventsWithStores = filterResourceSpans(request.resourceSpans).flatMap((resourceSpan) =>
47+
convertSpansToCreateableEvents(
48+
resourceSpan,
49+
task.spanAttributeValueLengthLimit,
50+
task.defaultEventStore
51+
)
52+
);
53+
for (const group of eventsWithStores) {
54+
group.events = enrichCreatableEvents(group.events);
55+
}
56+
return { eventsWithStores };
57+
}
58+
59+
if (task.kind === "logs") {
60+
const request = ExportLogsServiceRequest.decode(bytes);
61+
const eventsWithStores = filterResourceLogs(request.resourceLogs).flatMap((resourceLog) =>
62+
convertLogsToCreateableEvents(
63+
resourceLog,
64+
task.spanAttributeValueLengthLimit,
65+
task.defaultEventStore
66+
)
67+
);
68+
for (const group of eventsWithStores) {
69+
group.events = enrichCreatableEvents(group.events);
70+
}
71+
return { eventsWithStores };
72+
}
73+
74+
const request = ExportMetricsServiceRequest.decode(bytes);
75+
const rows = filterResourceMetrics(request.resourceMetrics).flatMap((resourceMetrics) =>
76+
convertMetricsToClickhouseRows(resourceMetrics, task.spanAttributeValueLengthLimit)
77+
);
78+
return { rows };
79+
}
80+
81+
if (!parentPort) {
82+
throw new Error("otlpTransformWorker must be run as a worker thread");
83+
}
84+
85+
parentPort.on("message", (message: TransformTask | PricingUpdate) => {
86+
if ("type" in message && message.type === "pricing") {
87+
applyPricing(message.models);
88+
return;
89+
}
90+
91+
const task = message as TransformTask;
92+
try {
93+
const result = runTask(task);
94+
parentPort!.postMessage({ id: task.id, ok: true, result });
95+
} catch (error) {
96+
parentPort!.postMessage({
97+
id: task.id,
98+
ok: false,
99+
error: error instanceof Error ? error.message : String(error),
100+
});
101+
}
102+
});

0 commit comments

Comments
 (0)