@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.
- package/CHANGELOG.md +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +293 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +2 -2
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +68 -0
- package/src/lib/env.ts +10 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +131 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +54 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- 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
|
-
|
|
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)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 (
|
|
356
|
+
if (isTracesRoute) {
|
|
187
357
|
this._ingestTraces(payload);
|
|
188
|
-
} else if (
|
|
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
|
-
|
|
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:
|
|
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>>;
|