@openwop/openwop-conformance 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +293 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +2 -2
  19. package/schemas/security-advisory.schema.json +109 -0
  20. package/src/lib/a2a-fake-peer.ts +143 -56
  21. package/src/lib/behavior-gate.ts +68 -0
  22. package/src/lib/env.ts +10 -0
  23. package/src/lib/grpc-framing.test.ts +96 -0
  24. package/src/lib/grpc-framing.ts +76 -0
  25. package/src/lib/oidc-issuer.test.ts +328 -0
  26. package/src/lib/oidc-issuer.ts +241 -0
  27. package/src/lib/otel-collector-grpc.test.ts +191 -0
  28. package/src/lib/otel-collector.test.ts +303 -0
  29. package/src/lib/otel-collector.ts +318 -14
  30. package/src/lib/otlp-protobuf.test.ts +461 -0
  31. package/src/lib/otlp-protobuf.ts +529 -0
  32. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  33. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  34. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  37. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  38. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  39. package/src/scenarios/agentMetadata.test.ts +1 -0
  40. package/src/scenarios/agentPackExport.test.ts +1 -0
  41. package/src/scenarios/agentPackInstall.test.ts +1 -0
  42. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  43. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  44. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  45. package/src/scenarios/auth-mtls.test.ts +274 -0
  46. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  47. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  48. package/src/scenarios/bulk-cancel.test.ts +111 -0
  49. package/src/scenarios/configurable-schema.test.ts +48 -0
  50. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  51. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  52. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  53. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  54. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  55. package/src/scenarios/discovery.test.ts +183 -0
  56. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  57. package/src/scenarios/idempotency.test.ts +6 -0
  58. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  59. package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
  60. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  61. package/src/scenarios/metric-emission.test.ts +113 -0
  62. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  63. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  64. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  65. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  66. package/src/scenarios/pause-resume.test.ts +119 -0
  67. package/src/scenarios/production-backpressure.test.ts +342 -0
  68. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  69. package/src/scenarios/registry-public.test.ts +131 -0
  70. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  71. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  72. package/src/scenarios/restart-during-run.test.ts +177 -0
  73. package/src/scenarios/spec-corpus-validity.test.ts +54 -26
  74. package/src/scenarios/staleClaim.test.ts +3 -0
  75. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  76. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  77. package/src/scenarios/webhook-negative.test.ts +90 -0
  78. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  79. package/src/setup.ts +25 -1
  80. package/vitest.config.ts +5 -1
@@ -0,0 +1,303 @@
1
+ /**
2
+ * End-to-end unit tests for the OTel collector's HTTP receiver.
3
+ *
4
+ * Boots the collector on an ephemeral port, posts synthesized OTLP
5
+ * payloads (both JSON and protobuf), and asserts the collector
6
+ * correctly captures them. Closes the gap the senior code-review pass
7
+ * flagged as MEDIUM-3 — the protobuf decoder has 18 unit tests, but
8
+ * those don't exercise the collector's HTTP receive wiring
9
+ * (content-type routing, body-size guard, error responses).
10
+ *
11
+ * Server-free (binds to 127.0.0.1 on an ephemeral port; no host required).
12
+ *
13
+ * @see conformance/src/lib/otel-collector.ts
14
+ * @see conformance/src/lib/otlp-protobuf.ts
15
+ */
16
+
17
+ import { afterAll, beforeAll, describe, it, expect } from 'vitest';
18
+ import { OtelCollector } from './otel-collector.js';
19
+
20
+ // ─── Minimal in-test OTLP/protobuf encoder ─────────────────────────────────
21
+ // Hand-rolled so the e2e test doesn't depend on the decoder's own test file
22
+ // re-exporting its writer. ~50 LOC; only encodes the wire-format subset this
23
+ // file actually emits.
24
+
25
+ const WIRE_I64 = 1;
26
+ const WIRE_LEN = 2;
27
+
28
+ function encVarint(out: number[], v: number | bigint): void {
29
+ let x = typeof v === 'bigint' ? v : BigInt(v);
30
+ while (x >= 0x80n) {
31
+ out.push(Number(x & 0x7fn) | 0x80);
32
+ x >>= 7n;
33
+ }
34
+ out.push(Number(x & 0x7fn));
35
+ }
36
+
37
+ function encTag(out: number[], field: number, wire: number): void {
38
+ encVarint(out, (field << 3) | wire);
39
+ }
40
+
41
+ function encString(out: number[], field: number, s: string): void {
42
+ const bytes = new TextEncoder().encode(s);
43
+ encTag(out, field, WIRE_LEN);
44
+ encVarint(out, bytes.length);
45
+ for (const b of bytes) out.push(b);
46
+ }
47
+
48
+ function encBytesHex(out: number[], field: number, hex: string): void {
49
+ const len = hex.length / 2;
50
+ encTag(out, field, WIRE_LEN);
51
+ encVarint(out, len);
52
+ for (let i = 0; i < len; i++) {
53
+ out.push(Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16));
54
+ }
55
+ }
56
+
57
+ function encFixed64(out: number[], field: number, v: bigint): void {
58
+ encTag(out, field, WIRE_I64);
59
+ const buf = new ArrayBuffer(8);
60
+ new DataView(buf).setBigUint64(0, v, true);
61
+ for (let i = 0; i < 8; i++) out.push(new Uint8Array(buf)[i]);
62
+ }
63
+
64
+ function encMessage(out: number[], field: number, body: number[]): void {
65
+ encTag(out, field, WIRE_LEN);
66
+ encVarint(out, body.length);
67
+ for (const b of body) out.push(b);
68
+ }
69
+
70
+ function buildMinimalProtobufExportTrace(spanName: string, traceIdHex: string, runIdAttr: string): Uint8Array {
71
+ // KeyValue { key: "openwop.run_id", value: { stringValue: runIdAttr } }
72
+ const anyValue: number[] = [];
73
+ encString(anyValue, 1, runIdAttr);
74
+ const kv: number[] = [];
75
+ encString(kv, 1, 'openwop.run_id');
76
+ encMessage(kv, 2, anyValue);
77
+
78
+ // Span { trace_id, span_id, name, start, end, attributes }
79
+ const span: number[] = [];
80
+ encBytesHex(span, 1, traceIdHex);
81
+ encBytesHex(span, 2, '0123456789abcdef');
82
+ encString(span, 5, spanName);
83
+ encFixed64(span, 7, 1700000000000000000n);
84
+ encFixed64(span, 8, 1700000000100000000n);
85
+ encMessage(span, 9, kv);
86
+
87
+ // ScopeSpans { spans: [span] }
88
+ const scopeSpans: number[] = [];
89
+ encMessage(scopeSpans, 2, span);
90
+
91
+ // ResourceSpans { scope_spans: [scopeSpans] }
92
+ const resourceSpans: number[] = [];
93
+ encMessage(resourceSpans, 2, scopeSpans);
94
+
95
+ // ExportTraceServiceRequest { resource_spans: [resourceSpans] }
96
+ const req: number[] = [];
97
+ encMessage(req, 1, resourceSpans);
98
+
99
+ return new Uint8Array(req);
100
+ }
101
+
102
+ // ─── Test fixture ──────────────────────────────────────────────────────────
103
+
104
+ let collector: OtelCollector;
105
+ let endpoint: string;
106
+
107
+ beforeAll(async () => {
108
+ collector = new OtelCollector();
109
+ await collector.start(0);
110
+ endpoint = collector.endpoint();
111
+ });
112
+
113
+ afterAll(async () => {
114
+ await collector.stop();
115
+ });
116
+
117
+ // ─── Tests ─────────────────────────────────────────────────────────────────
118
+
119
+ describe('OtelCollector: HTTP receiver wiring', () => {
120
+ it('accepts OTLP/HTTP-protobuf POST on /v1/traces and captures spans', async () => {
121
+ collector.reset();
122
+ const body = buildMinimalProtobufExportTrace(
123
+ 'openwop.run',
124
+ '0123456789abcdef0123456789abcdef',
125
+ 'run-pb-e2e',
126
+ );
127
+
128
+ const res = await fetch(`${endpoint}/v1/traces`, {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/x-protobuf' },
131
+ body,
132
+ });
133
+
134
+ expect(res.status).toBe(200);
135
+
136
+ const captured = collector.spansWithAttribute('openwop.run_id', 'run-pb-e2e');
137
+ expect(captured.length).toBe(1);
138
+ expect(captured[0].name).toBe('openwop.run');
139
+ expect(captured[0].traceId).toBe('0123456789abcdef0123456789abcdef');
140
+ });
141
+
142
+ it('accepts OTLP/HTTP-JSON POST on /v1/traces and captures spans', async () => {
143
+ collector.reset();
144
+ const payload = {
145
+ resourceSpans: [
146
+ {
147
+ resource: { attributes: [] },
148
+ scopeSpans: [
149
+ {
150
+ spans: [
151
+ {
152
+ traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
153
+ spanId: 'bbbbbbbbbbbbbbbb',
154
+ name: 'openwop.run',
155
+ startTimeUnixNano: '1700000000000000000',
156
+ endTimeUnixNano: '1700000000050000000',
157
+ attributes: [
158
+ { key: 'openwop.run_id', value: { stringValue: 'run-json-e2e' } },
159
+ ],
160
+ },
161
+ ],
162
+ },
163
+ ],
164
+ },
165
+ ],
166
+ };
167
+
168
+ const res = await fetch(`${endpoint}/v1/traces`, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify(payload),
172
+ });
173
+
174
+ expect(res.status).toBe(200);
175
+
176
+ const captured = collector.spansWithAttribute('openwop.run_id', 'run-json-e2e');
177
+ expect(captured.length).toBe(1);
178
+ expect(captured[0].name).toBe('openwop.run');
179
+ });
180
+
181
+ // Note: the collector also accepts "no Content-Type" as JSON for
182
+ // back-compat with non-spec OTLP clients. fetch() can't reproduce
183
+ // that case — Node automatically sets Content-Type when given a body
184
+ // — so the empty-content-type path is exercised only via direct
185
+ // node:http use (out of scope for this e2e suite).
186
+
187
+ it('returns 415 for an unsupported Content-Type', async () => {
188
+ collector.reset();
189
+ const res = await fetch(`${endpoint}/v1/traces`, {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'text/csv' },
192
+ body: 'a,b,c\n1,2,3',
193
+ });
194
+
195
+ expect(res.status).toBe(415);
196
+ const body = (await res.json()) as { error?: string; message?: string };
197
+ expect(body.error).toBe('unsupported_media_type');
198
+ expect(body.message).toContain('text/csv');
199
+ expect(collector.spans().length).toBe(0);
200
+ });
201
+
202
+ it('returns 400 for malformed JSON', async () => {
203
+ collector.reset();
204
+ const res = await fetch(`${endpoint}/v1/traces`, {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: '{ this is not valid json',
208
+ });
209
+
210
+ expect(res.status).toBe(400);
211
+ const body = (await res.json()) as { error?: string };
212
+ expect(body.error).toBe('invalid_json');
213
+ });
214
+
215
+ it('returns 400 for malformed protobuf', async () => {
216
+ collector.reset();
217
+ // Garbage bytes — first byte is a tag for field 0 (invalid) which the
218
+ // decoder skips, but the second byte is mid-varint with continuation
219
+ // bit set and no follow-up → readVarint throws on unexpected EOF.
220
+ const res = await fetch(`${endpoint}/v1/traces`, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/x-protobuf' },
223
+ body: new Uint8Array([0x0a, 0xff]),
224
+ });
225
+
226
+ expect(res.status).toBe(400);
227
+ const body = (await res.json()) as { error?: string };
228
+ expect(body.error).toBe('invalid_protobuf');
229
+ });
230
+
231
+ it('returns 405 for non-POST methods', async () => {
232
+ collector.reset();
233
+ const res = await fetch(`${endpoint}/v1/traces`, { method: 'GET' });
234
+ expect(res.status).toBe(405);
235
+ });
236
+
237
+ it('returns 413 when body exceeds 16 MiB cap', async () => {
238
+ collector.reset();
239
+ // 16 MiB + 1 byte. Use a fresh ArrayBuffer to avoid TypedArray-cap issues.
240
+ const oversize = new Uint8Array(16 * 1024 * 1024 + 1);
241
+ oversize.fill(0x00);
242
+
243
+ const res = await fetch(`${endpoint}/v1/traces`, {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/x-protobuf' },
246
+ body: oversize,
247
+ });
248
+
249
+ expect(res.status).toBe(413);
250
+ const body = (await res.json()) as { error?: string };
251
+ expect(body.error).toBe('payload_too_large');
252
+ }, 30_000); // larger timeout — uploading 16 MiB to localhost still costs a few hundred ms
253
+
254
+ it('routes /v1/metrics to the metrics ingest path (JSON)', async () => {
255
+ collector.reset();
256
+ const payload = {
257
+ resourceMetrics: [
258
+ {
259
+ scopeMetrics: [
260
+ {
261
+ metrics: [
262
+ {
263
+ name: 'openwop.queue.depth',
264
+ unit: 'count',
265
+ gauge: {
266
+ dataPoints: [
267
+ {
268
+ asDouble: 3,
269
+ attributes: [],
270
+ },
271
+ ],
272
+ },
273
+ },
274
+ ],
275
+ },
276
+ ],
277
+ },
278
+ ],
279
+ };
280
+ const res = await fetch(`${endpoint}/v1/metrics`, {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify(payload),
284
+ });
285
+ expect(res.status).toBe(200);
286
+ const m = collector.metricByName('openwop.queue.depth');
287
+ expect(m).toBeDefined();
288
+ expect(m?.kind).toBe('gauge');
289
+ expect(m?.dataPoint.value).toBe(3);
290
+ });
291
+
292
+ it('200-OKs unknown paths without ingesting (forward-compat for /v1/logs)', async () => {
293
+ collector.reset();
294
+ const res = await fetch(`${endpoint}/v1/logs`, {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: '{}',
298
+ });
299
+ expect(res.status).toBe(200);
300
+ expect(collector.spans().length).toBe(0);
301
+ expect(collector.metrics().length).toBe(0);
302
+ });
303
+ });
@@ -31,13 +31,35 @@
31
31
  */
32
32
 
33
33
  import { createServer, type Server } from 'node:http';
34
+ import { createServer as createHttp2Server, type Http2Server, type ServerHttp2Stream, type IncomingHttpHeaders } from 'node:http2';
34
35
  import type { AddressInfo } from 'node:net';
36
+ import { frameMessage, unframeMessages } from './grpc-framing.js';
37
+ import {
38
+ decodeExportTraceServiceRequest,
39
+ decodeExportMetricsServiceRequest,
40
+ } from './otlp-protobuf.js';
35
41
 
36
42
  export interface OtelAttribute {
37
43
  readonly key: string;
38
44
  readonly value: string | number | boolean | null | readonly unknown[];
39
45
  }
40
46
 
47
+ /**
48
+ * Narrow structural type the `_ingestTraces` helper accepts. Both the
49
+ * JSON parser (`JSON.parse(body) → unknown`, cast to this shape) and
50
+ * the protobuf decoder (`decodeExportTraceServiceRequest → JsonExport-
51
+ * TraceServiceRequest`) flow through this interface without needing
52
+ * `as unknown as Record<string, unknown>` double-casts.
53
+ */
54
+ export interface TracesIngest {
55
+ resourceSpans?: unknown;
56
+ }
57
+
58
+ /** Same idea for the metrics ingest path. */
59
+ export interface MetricsIngest {
60
+ resourceMetrics?: unknown;
61
+ }
62
+
41
63
  export interface CapturedSpan {
42
64
  readonly traceId: string;
43
65
  readonly spanId: string;
@@ -97,6 +119,12 @@ export class OtelCollector {
97
119
  private readonly _metrics: CapturedMetric[] = [];
98
120
  private _server: Server | null = null;
99
121
  private _boundPort: number = 0;
122
+ // OTLP/gRPC parallel server (Track 11). Boots when `startGrpc()` is
123
+ // called. Shares `_spans` + `_metrics` with the HTTP collector so
124
+ // scenarios can assert on captured data regardless of which transport
125
+ // the host used to emit.
126
+ private _grpcServer: Http2Server | null = null;
127
+ private _grpcBoundPort: number = 0;
100
128
 
101
129
  /**
102
130
  * Start the collector. If `port` is `0` (or unset), an ephemeral port
@@ -124,6 +152,53 @@ export class OtelCollector {
124
152
  });
125
153
  }
126
154
 
155
+ /**
156
+ * Start the OTLP/gRPC server alongside the HTTP one. Uses Node's
157
+ * stdlib `http2` (h2c — cleartext HTTP/2). Same `_spans` + `_metrics`
158
+ * store; spans captured here are visible to `spans()` /
159
+ * `spansByName()` / etc. exactly like HTTP-emitted ones.
160
+ *
161
+ * @param port Bind port; `0` (default) picks an ephemeral port.
162
+ */
163
+ async startGrpc(port: number = 0): Promise<void> {
164
+ return new Promise((resolve, reject) => {
165
+ const server = createHttp2Server();
166
+ server.on('error', reject);
167
+ server.on('stream', (stream, headers) => {
168
+ this._handleGrpcStream(stream, headers).catch((err) => {
169
+ // eslint-disable-next-line no-console
170
+ console.error('[otel-collector-grpc] stream error:', err);
171
+ if (!stream.closed) {
172
+ stream.respond(
173
+ {
174
+ ':status': 200,
175
+ 'content-type': 'application/grpc+proto',
176
+ 'grpc-status': '13', // INTERNAL
177
+ 'grpc-message': String((err as Error).message ?? err),
178
+ },
179
+ { endStream: true },
180
+ );
181
+ }
182
+ });
183
+ });
184
+ server.listen(port, '127.0.0.1', () => {
185
+ const addr = server.address() as AddressInfo;
186
+ this._grpcServer = server;
187
+ this._grpcBoundPort = addr.port;
188
+ resolve();
189
+ });
190
+ });
191
+ }
192
+
193
+ async stopGrpc(): Promise<void> {
194
+ if (!this._grpcServer) return;
195
+ const server = this._grpcServer;
196
+ this._grpcServer = null;
197
+ return new Promise((resolve, reject) => {
198
+ server.close((err) => (err ? reject(err) : resolve()));
199
+ });
200
+ }
201
+
127
202
  boundPort(): number {
128
203
  return this._boundPort;
129
204
  }
@@ -132,6 +207,15 @@ export class OtelCollector {
132
207
  return `http://127.0.0.1:${this._boundPort}`;
133
208
  }
134
209
 
210
+ grpcBoundPort(): number {
211
+ return this._grpcBoundPort;
212
+ }
213
+
214
+ /** h2c base URL (no scheme distinction — gRPC clients use `:authority` form). */
215
+ grpcEndpoint(): string {
216
+ return `http://127.0.0.1:${this._grpcBoundPort}`;
217
+ }
218
+
135
219
  reset(): void {
136
220
  this._spans.length = 0;
137
221
  this._metrics.length = 0;
@@ -167,25 +251,111 @@ export class OtelCollector {
167
251
  res.writeHead(405).end();
168
252
  return;
169
253
  }
254
+ // Defense-in-depth: cap inbound body size before buffering. The
255
+ // collector runs in the conformance suite process; a runaway host
256
+ // (or operator misconfig) emitting a multi-GB OTLP payload would
257
+ // otherwise OOM the suite. 16 MiB is generous for normal OTLP
258
+ // traffic (a 100-span batch with 50-attribute payloads runs ~50 KiB
259
+ // in JSON) but bounds the worst case. Hosts hitting this limit
260
+ // should batch smaller; the cap is suite-side, not normative.
261
+ const MAX_BODY_BYTES = 16 * 1024 * 1024;
170
262
  const chunks: Buffer[] = [];
263
+ let received = 0;
171
264
  for await (const c of req) {
172
- chunks.push(c as Buffer);
265
+ const buf = c as Buffer;
266
+ received += buf.length;
267
+ if (received > MAX_BODY_BYTES) {
268
+ res
269
+ .writeHead(413, { 'Content-Type': 'application/json' })
270
+ .end(
271
+ JSON.stringify({
272
+ error: 'payload_too_large',
273
+ message: `OTLP body exceeds ${MAX_BODY_BYTES} bytes; reduce batch size or split exports`,
274
+ }),
275
+ );
276
+ req.destroy();
277
+ return;
278
+ }
279
+ chunks.push(buf);
173
280
  }
174
- const body = Buffer.concat(chunks).toString('utf8');
175
- let payload: Record<string, unknown> = {};
176
- try {
177
- payload = body.length > 0 ? (JSON.parse(body) as Record<string, unknown>) : {};
178
- } catch {
179
- // Protobuf-encoded OTLP arrives as binary; we currently support JSON only.
180
- // Hosts that emit protobuf-encoded OTLP need to be configured for
181
- // `OTEL_EXPORTER_OTLP_PROTOCOL=http/json`.
182
- res.writeHead(415).end('OTLP/HTTP-JSON only');
281
+ const body = Buffer.concat(chunks);
282
+ const contentType = (req.headers['content-type'] ?? '').toLowerCase();
283
+ const isProtobuf =
284
+ contentType.includes('application/x-protobuf') ||
285
+ contentType.includes('application/protobuf');
286
+ const isJson = contentType.includes('application/json') || contentType === '';
287
+
288
+ const isTracesRoute = req.url?.includes('/v1/traces') === true;
289
+ const isMetricsRoute = req.url?.includes('/v1/metrics') === true;
290
+
291
+ // Both the JSON parser and the protobuf decoder produce objects
292
+ // with the same structural shape. Typing `payload` as the union
293
+ // narrow-property interface (instead of `Record<string, unknown>`)
294
+ // lets both paths flow in without `as unknown as` double-casts.
295
+ let payload: TracesIngest & MetricsIngest = {};
296
+
297
+ if (isProtobuf) {
298
+ // OTLP/HTTP-protobuf — added in the Track 11 follow-up. Decode the
299
+ // binary message via the in-suite hand-rolled decoder
300
+ // (`otlp-protobuf.ts`) and produce the same JSON-shaped object
301
+ // the existing `_ingestTraces` / `_ingestMetrics` already consume.
302
+ try {
303
+ if (isTracesRoute) {
304
+ payload = decodeExportTraceServiceRequest(
305
+ new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
306
+ );
307
+ } else if (isMetricsRoute) {
308
+ payload = decodeExportMetricsServiceRequest(
309
+ new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
310
+ );
311
+ } else {
312
+ // /v1/logs or unknown — ack and ignore so the exporter doesn't retry.
313
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
314
+ return;
315
+ }
316
+ } catch (err) {
317
+ res
318
+ .writeHead(400, { 'Content-Type': 'application/json' })
319
+ .end(
320
+ JSON.stringify({
321
+ error: 'invalid_protobuf',
322
+ message: `OTLP/HTTP-protobuf decode failed: ${(err as Error).message ?? 'unknown'}`,
323
+ }),
324
+ );
325
+ return;
326
+ }
327
+ } else if (isJson) {
328
+ try {
329
+ payload =
330
+ body.length > 0
331
+ ? (JSON.parse(body.toString('utf8')) as TracesIngest & MetricsIngest)
332
+ : {};
333
+ } catch {
334
+ res
335
+ .writeHead(400, { 'Content-Type': 'application/json' })
336
+ .end(
337
+ JSON.stringify({
338
+ error: 'invalid_json',
339
+ message: 'OTLP/HTTP-JSON body did not parse',
340
+ }),
341
+ );
342
+ return;
343
+ }
344
+ } else {
345
+ res
346
+ .writeHead(415, { 'Content-Type': 'application/json' })
347
+ .end(
348
+ JSON.stringify({
349
+ error: 'unsupported_media_type',
350
+ message: `OTLP collector accepts application/json or application/x-protobuf; got "${contentType}"`,
351
+ }),
352
+ );
183
353
  return;
184
354
  }
185
355
 
186
- if (req.url?.includes('/v1/traces')) {
356
+ if (isTracesRoute) {
187
357
  this._ingestTraces(payload);
188
- } else if (req.url?.includes('/v1/metrics')) {
358
+ } else if (isMetricsRoute) {
189
359
  this._ingestMetrics(payload);
190
360
  } else {
191
361
  // Ignore /v1/logs and unknown paths; respond OK so the exporter doesn't retry.
@@ -194,7 +364,141 @@ export class OtelCollector {
194
364
  res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
195
365
  }
196
366
 
197
- private _ingestTraces(payload: Record<string, unknown>): void {
367
+ /**
368
+ * Handle a single OTLP/gRPC HTTP/2 stream. gRPC unary Export calls
369
+ * deliver one length-prefixed protobuf message in the request body
370
+ * and expect a length-prefixed Empty response message plus a
371
+ * `grpc-status: 0` trailer.
372
+ *
373
+ * Routing per the OTLP service definitions:
374
+ * POST /opentelemetry.proto.collector.trace.v1.TraceService/Export → traces
375
+ * POST /opentelemetry.proto.collector.metrics.v1.MetricsService/Export → metrics
376
+ *
377
+ * @see https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
378
+ * @see https://opentelemetry.io/docs/specs/otlp/#otlpgrpc
379
+ */
380
+ private async _handleGrpcStream(
381
+ stream: ServerHttp2Stream,
382
+ headers: IncomingHttpHeaders,
383
+ ): Promise<void> {
384
+ const path = String(headers[':path'] ?? '');
385
+ const method = String(headers[':method'] ?? '');
386
+ const contentType = String(headers['content-type'] ?? '').toLowerCase();
387
+
388
+ if (method !== 'POST' || !contentType.startsWith('application/grpc')) {
389
+ // gRPC servers respond on HTTP/2 with :status 200 and signal the
390
+ // error via grpc-status + grpc-message trailers. Non-gRPC clients
391
+ // can't easily reach this endpoint so the error mode is mostly
392
+ // defensive against operator misconfiguration.
393
+ stream.respond(
394
+ {
395
+ ':status': 200,
396
+ 'content-type': 'application/grpc+proto',
397
+ 'grpc-status': '3', // INVALID_ARGUMENT
398
+ 'grpc-message': `expected POST with content-type application/grpc+proto, got ${method} ${contentType}`,
399
+ },
400
+ { endStream: true },
401
+ );
402
+ return;
403
+ }
404
+
405
+ // Read the request body. gRPC unary requests have a single
406
+ // length-prefixed frame; defensively bound the total size so a
407
+ // runaway peer can't OOM the suite.
408
+ const MAX_BODY_BYTES = 16 * 1024 * 1024;
409
+ const chunks: Buffer[] = [];
410
+ let received = 0;
411
+ for await (const c of stream) {
412
+ const buf = c as Buffer;
413
+ received += buf.length;
414
+ if (received > MAX_BODY_BYTES) {
415
+ stream.respond(
416
+ {
417
+ ':status': 200,
418
+ 'content-type': 'application/grpc+proto',
419
+ 'grpc-status': '8', // RESOURCE_EXHAUSTED
420
+ 'grpc-message': `OTLP body exceeds ${MAX_BODY_BYTES} bytes`,
421
+ },
422
+ { endStream: true },
423
+ );
424
+ return;
425
+ }
426
+ chunks.push(buf);
427
+ }
428
+ const body = Buffer.concat(chunks);
429
+
430
+ let frames: Uint8Array[];
431
+ try {
432
+ frames = unframeMessages(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
433
+ } catch (err) {
434
+ stream.respond(
435
+ {
436
+ ':status': 200,
437
+ 'content-type': 'application/grpc+proto',
438
+ 'grpc-status': '3', // INVALID_ARGUMENT
439
+ 'grpc-message': `gRPC frame parse failed: ${(err as Error).message ?? 'unknown'}`,
440
+ },
441
+ { endStream: true },
442
+ );
443
+ return;
444
+ }
445
+
446
+ // gRPC URLs are `/<package>.<Service>/<Method>`. The Service is
447
+ // package-qualified so the character before "TraceService" /
448
+ // "MetricsService" is `.` (part of the package), not `/`. Match on
449
+ // the Service.Method suffix.
450
+ const isTracesRoute = path.endsWith('TraceService/Export');
451
+ const isMetricsRoute = path.endsWith('MetricsService/Export');
452
+
453
+ if (isTracesRoute || isMetricsRoute) {
454
+ try {
455
+ for (const frame of frames) {
456
+ if (isTracesRoute) {
457
+ const payload = decodeExportTraceServiceRequest(frame);
458
+ this._ingestTraces(payload as TracesIngest);
459
+ } else if (isMetricsRoute) {
460
+ const payload = decodeExportMetricsServiceRequest(frame);
461
+ this._ingestMetrics(payload as MetricsIngest);
462
+ }
463
+ }
464
+ } catch (err) {
465
+ stream.respond(
466
+ {
467
+ ':status': 200,
468
+ 'content-type': 'application/grpc+proto',
469
+ 'grpc-status': '3', // INVALID_ARGUMENT
470
+ 'grpc-message': `OTLP protobuf decode failed: ${(err as Error).message ?? 'unknown'}`,
471
+ },
472
+ { endStream: true },
473
+ );
474
+ return;
475
+ }
476
+ }
477
+ // Unknown service/method paths fall through to a success response
478
+ // so the exporter doesn't retry on /v1/logs or similar surfaces we
479
+ // don't capture; spans/metrics stay un-ingested.
480
+
481
+ // Per OTLP spec, the Export response is an empty ExportTraceServiceResponse
482
+ // (or ExportMetricsServiceResponse) — zero-byte protobuf. gRPC over
483
+ // HTTP/2 sends headers + body + trailers; in Node's API we set
484
+ // `waitForTrailers` on respond() so the stream emits a
485
+ // `wantTrailers` event after the body, at which point we call
486
+ // sendTrailers() and the stream closes cleanly.
487
+ const responseBody = frameMessage(new Uint8Array(0));
488
+ stream.on('wantTrailers', () => {
489
+ stream.sendTrailers({ 'grpc-status': '0' });
490
+ });
491
+ stream.respond(
492
+ {
493
+ ':status': 200,
494
+ 'content-type': 'application/grpc+proto',
495
+ },
496
+ { waitForTrailers: true },
497
+ );
498
+ stream.end(responseBody);
499
+ }
500
+
501
+ private _ingestTraces(payload: TracesIngest): void {
198
502
  const rs = (payload.resourceSpans ?? []) as ReadonlyArray<Record<string, unknown>>;
199
503
  for (const r of rs) {
200
504
  const resourceAttrs = decodeAttributes(
@@ -222,7 +526,7 @@ export class OtelCollector {
222
526
  }
223
527
  }
224
528
 
225
- private _ingestMetrics(payload: Record<string, unknown>): void {
529
+ private _ingestMetrics(payload: MetricsIngest): void {
226
530
  const rm = (payload.resourceMetrics ?? []) as ReadonlyArray<Record<string, unknown>>;
227
531
  for (const r of rm) {
228
532
  const sm = (r.scopeMetrics ?? []) as ReadonlyArray<Record<string, unknown>>;