@traffic-twin/sdk 0.0.0-stage → 0.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 (133) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +171 -2
  3. package/dist/cjs/agent.d.ts +143 -0
  4. package/dist/cjs/agent.js +927 -0
  5. package/dist/cjs/capture/body.d.ts +11 -0
  6. package/dist/cjs/capture/body.js +73 -0
  7. package/dist/cjs/capture/common.d.ts +75 -0
  8. package/dist/cjs/capture/common.js +180 -0
  9. package/dist/cjs/capture/context.d.ts +28 -0
  10. package/dist/cjs/capture/context.js +92 -0
  11. package/dist/cjs/capture/inbound.d.ts +12 -0
  12. package/dist/cjs/capture/inbound.js +195 -0
  13. package/dist/cjs/capture/outbound-http.d.ts +4 -0
  14. package/dist/cjs/capture/outbound-http.js +200 -0
  15. package/dist/cjs/capture/outbound-undici.d.ts +2 -0
  16. package/dist/cjs/capture/outbound-undici.js +242 -0
  17. package/dist/cjs/capture/wire.d.ts +27 -0
  18. package/dist/cjs/capture/wire.js +68 -0
  19. package/dist/cjs/export/event.d.ts +46 -0
  20. package/dist/cjs/export/event.js +91 -0
  21. package/dist/cjs/export/exporter.d.ts +50 -0
  22. package/dist/cjs/export/exporter.js +192 -0
  23. package/dist/cjs/export/pipeline.d.ts +20 -0
  24. package/dist/cjs/export/pipeline.js +40 -0
  25. package/dist/cjs/fleet/asn1.d.ts +44 -0
  26. package/dist/cjs/fleet/asn1.js +150 -0
  27. package/dist/cjs/fleet/client.d.ts +84 -0
  28. package/dist/cjs/fleet/client.js +216 -0
  29. package/dist/cjs/fleet/http.d.ts +17 -0
  30. package/dist/cjs/fleet/http.js +80 -0
  31. package/dist/cjs/fleet/store.d.ts +18 -0
  32. package/dist/cjs/fleet/store.js +115 -0
  33. package/dist/cjs/index.d.ts +14 -0
  34. package/dist/cjs/index.js +69 -0
  35. package/dist/cjs/log.d.ts +4 -0
  36. package/dist/cjs/log.js +30 -0
  37. package/dist/cjs/package.json +1 -0
  38. package/dist/cjs/register.d.ts +1 -0
  39. package/dist/cjs/register.js +18 -0
  40. package/dist/cjs/sanitizer/bodies.d.ts +55 -0
  41. package/dist/cjs/sanitizer/bodies.js +710 -0
  42. package/dist/cjs/sanitizer/formats.d.ts +26 -0
  43. package/dist/cjs/sanitizer/formats.js +110 -0
  44. package/dist/cjs/sanitizer/graphql.d.ts +23 -0
  45. package/dist/cjs/sanitizer/graphql.js +478 -0
  46. package/dist/cjs/sanitizer/hostpat.d.ts +15 -0
  47. package/dist/cjs/sanitizer/hostpat.js +129 -0
  48. package/dist/cjs/sanitizer/json.d.ts +60 -0
  49. package/dist/cjs/sanitizer/json.js +460 -0
  50. package/dist/cjs/sanitizer/policy.d.ts +224 -0
  51. package/dist/cjs/sanitizer/policy.js +820 -0
  52. package/dist/cjs/sanitizer/sanitizer.d.ts +149 -0
  53. package/dist/cjs/sanitizer/sanitizer.js +1473 -0
  54. package/dist/cjs/sanitizer/utf8.d.ts +22 -0
  55. package/dist/cjs/sanitizer/utf8.js +180 -0
  56. package/dist/cjs/sanitizer/vectors.d.ts +22 -0
  57. package/dist/cjs/sanitizer/vectors.js +130 -0
  58. package/dist/cjs/version.d.ts +2 -0
  59. package/dist/cjs/version.js +5 -0
  60. package/dist/cjs/worker/dir.d.ts +1 -0
  61. package/dist/cjs/worker/dir.js +8 -0
  62. package/dist/cjs/worker/pool.d.ts +47 -0
  63. package/dist/cjs/worker/pool.js +179 -0
  64. package/dist/cjs/worker/protocol.d.ts +37 -0
  65. package/dist/cjs/worker/protocol.js +3 -0
  66. package/dist/cjs/worker/sanitizer-worker.d.ts +1 -0
  67. package/dist/cjs/worker/sanitizer-worker.js +53 -0
  68. package/dist/esm/agent.d.ts +143 -0
  69. package/dist/esm/agent.js +921 -0
  70. package/dist/esm/capture/body.d.ts +11 -0
  71. package/dist/esm/capture/body.js +69 -0
  72. package/dist/esm/capture/common.d.ts +75 -0
  73. package/dist/esm/capture/common.js +166 -0
  74. package/dist/esm/capture/context.d.ts +28 -0
  75. package/dist/esm/capture/context.js +84 -0
  76. package/dist/esm/capture/inbound.d.ts +12 -0
  77. package/dist/esm/capture/inbound.js +187 -0
  78. package/dist/esm/capture/outbound-http.d.ts +4 -0
  79. package/dist/esm/capture/outbound-http.js +193 -0
  80. package/dist/esm/capture/outbound-undici.d.ts +2 -0
  81. package/dist/esm/capture/outbound-undici.js +236 -0
  82. package/dist/esm/capture/wire.d.ts +27 -0
  83. package/dist/esm/capture/wire.js +65 -0
  84. package/dist/esm/export/event.d.ts +46 -0
  85. package/dist/esm/export/event.js +85 -0
  86. package/dist/esm/export/exporter.d.ts +50 -0
  87. package/dist/esm/export/exporter.js +188 -0
  88. package/dist/esm/export/pipeline.d.ts +20 -0
  89. package/dist/esm/export/pipeline.js +37 -0
  90. package/dist/esm/fleet/asn1.d.ts +44 -0
  91. package/dist/esm/fleet/asn1.js +128 -0
  92. package/dist/esm/fleet/client.d.ts +84 -0
  93. package/dist/esm/fleet/client.js +204 -0
  94. package/dist/esm/fleet/http.d.ts +17 -0
  95. package/dist/esm/fleet/http.js +73 -0
  96. package/dist/esm/fleet/store.d.ts +18 -0
  97. package/dist/esm/fleet/store.js +110 -0
  98. package/dist/esm/index.d.ts +14 -0
  99. package/dist/esm/index.js +48 -0
  100. package/dist/esm/log.d.ts +4 -0
  101. package/dist/esm/log.js +25 -0
  102. package/dist/esm/package.json +1 -0
  103. package/dist/esm/register.d.ts +1 -0
  104. package/dist/esm/register.js +16 -0
  105. package/dist/esm/sanitizer/bodies.d.ts +55 -0
  106. package/dist/esm/sanitizer/bodies.js +694 -0
  107. package/dist/esm/sanitizer/formats.d.ts +26 -0
  108. package/dist/esm/sanitizer/formats.js +101 -0
  109. package/dist/esm/sanitizer/graphql.d.ts +23 -0
  110. package/dist/esm/sanitizer/graphql.js +472 -0
  111. package/dist/esm/sanitizer/hostpat.d.ts +15 -0
  112. package/dist/esm/sanitizer/hostpat.js +122 -0
  113. package/dist/esm/sanitizer/json.d.ts +60 -0
  114. package/dist/esm/sanitizer/json.js +447 -0
  115. package/dist/esm/sanitizer/policy.d.ts +224 -0
  116. package/dist/esm/sanitizer/policy.js +800 -0
  117. package/dist/esm/sanitizer/sanitizer.d.ts +149 -0
  118. package/dist/esm/sanitizer/sanitizer.js +1459 -0
  119. package/dist/esm/sanitizer/utf8.d.ts +22 -0
  120. package/dist/esm/sanitizer/utf8.js +166 -0
  121. package/dist/esm/sanitizer/vectors.d.ts +22 -0
  122. package/dist/esm/sanitizer/vectors.js +125 -0
  123. package/dist/esm/version.d.ts +2 -0
  124. package/dist/esm/version.js +2 -0
  125. package/dist/esm/worker/dir.d.ts +1 -0
  126. package/dist/esm/worker/dir.js +5 -0
  127. package/dist/esm/worker/pool.d.ts +47 -0
  128. package/dist/esm/worker/pool.js +175 -0
  129. package/dist/esm/worker/protocol.d.ts +37 -0
  130. package/dist/esm/worker/protocol.js +2 -0
  131. package/dist/esm/worker/sanitizer-worker.d.ts +1 -0
  132. package/dist/esm/worker/sanitizer-worker.js +51 -0
  133. package/package.json +75 -4
@@ -0,0 +1,193 @@
1
+ // Outbound capture for node:http / node:https clients through diagnostics_channel
2
+ // (`http.client.request.start`, `http.client.response.finish`, `http.client.request.error`).
3
+ import diagnostics_channel from 'node:diagnostics_channel';
4
+ import { BodyTap } from './body.js';
5
+ import { hrNow, internal, internalAgents, netErrorOf, outboundTrace, outgoingHeadersWire, withTtS, } from './common.js';
6
+ import { als, newSpanId } from './context.js';
7
+ import { tapPush, tapWrites } from './inbound.js';
8
+ const EMPTY = Buffer.alloc(0);
9
+ /** How long an unread response body is waited for before the exchange is closed as not consumed. */
10
+ export const UNREAD_BODY_TIMEOUT_MS = 60_000;
11
+ const kState = Symbol('traffic-twin.outbound');
12
+ const kSeen = Symbol('traffic-twin.outbound.seen');
13
+ export function installOutboundHttp(host) {
14
+ // `http.client.request.created` (Node >= 22.12 / 23.2) fires in the ClientRequest
15
+ // constructor, before the app can write or end: body capture and the optional baggage
16
+ // member work there. On older runtimes `start` fires once a socket is assigned, which may
17
+ // be after end(): the body is then reported as unknown and nothing is injected.
18
+ const onCreated = (message) => {
19
+ if (internal.depth > 0)
20
+ return;
21
+ const t = hrNow();
22
+ try {
23
+ const request = message.request;
24
+ if (request[kSeen])
25
+ return;
26
+ request[kSeen] = true;
27
+ if (request.agent && internalAgents.has(request.agent))
28
+ return;
29
+ start(host, request, t);
30
+ }
31
+ catch {
32
+ host.noteError('capture_outbound_start');
33
+ }
34
+ };
35
+ const onStart = onCreated;
36
+ const onResponse = (message) => {
37
+ try {
38
+ const { request, response } = message;
39
+ const st = request[kState];
40
+ if (!st || st.done)
41
+ return;
42
+ st.response = response;
43
+ tapPush(response, st.resTap);
44
+ response.once('end', () => complete(host, request, true));
45
+ response.once('close', () => complete(host, request, false));
46
+ st.timer = setTimeout(() => complete(host, request, false), UNREAD_BODY_TIMEOUT_MS);
47
+ st.timer.unref();
48
+ }
49
+ catch {
50
+ host.noteError('capture_outbound_response');
51
+ }
52
+ };
53
+ const onError = (message) => {
54
+ try {
55
+ const { request, error } = message;
56
+ const st = request[kState];
57
+ if (st && !st.response)
58
+ st.netError = netErrorOf(error);
59
+ }
60
+ catch {
61
+ // ignore
62
+ }
63
+ };
64
+ diagnostics_channel.subscribe('http.client.request.created', onCreated);
65
+ diagnostics_channel.subscribe('http.client.request.start', onStart);
66
+ diagnostics_channel.subscribe('http.client.response.finish', onResponse);
67
+ diagnostics_channel.subscribe('http.client.request.error', onError);
68
+ return () => {
69
+ diagnostics_channel.unsubscribe('http.client.request.created', onCreated);
70
+ diagnostics_channel.unsubscribe('http.client.request.start', onStart);
71
+ diagnostics_channel.unsubscribe('http.client.response.finish', onResponse);
72
+ diagnostics_channel.unsubscribe('http.client.request.error', onError);
73
+ };
74
+ }
75
+ /** Authority when the request has no Host header: the host option, bracketed if it is an IPv6 literal. */
76
+ function fallbackAuthority(request) {
77
+ const h = String(request.host ?? '');
78
+ return h.includes(':') && !h.startsWith('[') ? '[' + h + ']' : h;
79
+ }
80
+ function start(host, request, t) {
81
+ const san = host.active();
82
+ if (!san)
83
+ return;
84
+ host.noteSeen();
85
+ const ctx = als.getStore();
86
+ if (ctx !== undefined && !ctx.sampled && !host.propagates()) {
87
+ // Inherited "not sampled" and nothing to inject: no work at all for this call.
88
+ host.noteOverhead(hrNow() - t);
89
+ return;
90
+ }
91
+ const hostHeader = request.getHeader('host');
92
+ const proto = typeof request.protocol === 'string' ? request.protocol : 'http:';
93
+ // The host as the client sees it (spec v1.5, point 9): the Host header as is, a trailing dot and
94
+ // an `xn--` form untouched, IPv6 with its brackets. No IDN conversion, no dot stripping.
95
+ const urlBase = proto + '//' + (typeof hostHeader === 'string' ? hostHeader : fallbackAuthority(request));
96
+ const tr = outboundTrace(ctx);
97
+ const decision = san.decide({ dir: 'outbound', req_headers: [], trace_id: tr.samplingTraceId, inherited: ctx?.sampled });
98
+ if (host.mayPropagate(urlBase) && !request.headersSent) {
99
+ try {
100
+ const prev = request.getHeader('baggage');
101
+ request.setHeader('baggage', withTtS(typeof prev === 'string' ? prev : Array.isArray(prev) ? prev.join(',') : undefined, decision.sampled));
102
+ }
103
+ catch {
104
+ // headers already flushed: leave the request untouched
105
+ }
106
+ }
107
+ if (!decision.sampled) {
108
+ host.noteOverhead(hrNow() - t);
109
+ return;
110
+ }
111
+ host.noteSampled();
112
+ const limit = san.policy.captureLimit; // max(max_body_bytes, bodies.max_large_body_bytes) + 1 (sanitizer.md 9.0, 22.5)
113
+ const st = {
114
+ san,
115
+ decision,
116
+ urlBase,
117
+ traceId: tr.traceId,
118
+ spanId: newSpanId(),
119
+ parentSpanId: tr.parentSpanId,
120
+ t0: performance.now(),
121
+ tsMs: Date.now(),
122
+ reqTap: new BodyTap(limit),
123
+ resTap: new BodyTap(limit),
124
+ done: false,
125
+ late: typeof request._header === 'string' || request.writableEnded || request.writableLength > 0,
126
+ overheadNs: 0,
127
+ };
128
+ tapWrites(request, st.reqTap);
129
+ request[kState] = st;
130
+ // 'close' never changes the app's error handling (unlike an 'error' listener).
131
+ request.once('close', () => {
132
+ if (!st.response)
133
+ complete(host, request, false);
134
+ });
135
+ st.overheadNs = hrNow() - t;
136
+ }
137
+ function complete(host, request, ended) {
138
+ const st = request[kState];
139
+ if (!st || st.done)
140
+ return;
141
+ const t = hrNow();
142
+ st.done = true;
143
+ request[kState] = undefined;
144
+ if (st.timer)
145
+ clearTimeout(st.timer);
146
+ try {
147
+ const latencyMs = Math.max(0, Math.floor(performance.now() - st.t0));
148
+ const res = st.response;
149
+ const netError = res ? undefined : st.netError ?? 'other';
150
+ const status = res?.statusCode;
151
+ const resConsumed = ended && st.resTap.ended;
152
+ const reqBody = st.reqTap.bytes();
153
+ const resBody = res && resConsumed ? st.resTap.bytes() : EMPTY;
154
+ const wire = {
155
+ dir: 'outbound',
156
+ url_base: st.urlBase,
157
+ method: request.method ?? 'GET',
158
+ target: request.path ?? '/',
159
+ inherited: st.decision.by === 'inherited' ? st.decision.sampled : undefined,
160
+ req_headers: outgoingHeadersWire(request),
161
+ req_body: reqBody,
162
+ req_body_total: st.late ? 'unknown' : st.reqTap.total,
163
+ };
164
+ if (netError) {
165
+ wire.net_error = netError;
166
+ }
167
+ else {
168
+ wire.status = status;
169
+ wire.resp_headers = res.rawHeaders;
170
+ wire.resp_body = resBody;
171
+ wire.resp_body_total = st.resTap.total;
172
+ wire.resp_body_consumed = resConsumed;
173
+ }
174
+ host.submit({
175
+ sanitizer: st.san,
176
+ decision: st.decision,
177
+ dir: 'outbound',
178
+ tsMs: st.tsMs,
179
+ latencyMs,
180
+ traceId: st.traceId,
181
+ spanId: st.spanId,
182
+ parentSpanId: st.parentSpanId,
183
+ status,
184
+ netError,
185
+ heldBytes: reqBody.length + resBody.length,
186
+ wire,
187
+ });
188
+ }
189
+ catch {
190
+ host.noteError('capture_outbound_complete');
191
+ }
192
+ host.noteOverhead(st.overheadNs + (hrNow() - t));
193
+ }
@@ -0,0 +1,2 @@
1
+ import { type CaptureHost } from './common.js';
2
+ export declare function installOutboundUndici(host: CaptureHost): () => void;
@@ -0,0 +1,236 @@
1
+ // Outbound capture for fetch / undici through its diagnostics channels
2
+ // (`undici:request:create`, `:headers`, `:bodyChunkSent`, `:bodyChunkReceived`,
3
+ // `:trailers`, `:error`). Body chunk channels exist in recent undici releases (Node 24);
4
+ // where they are missing, bodies the SDK cannot see are reported as unknown length
5
+ // (sanitizer.md 9.0: `trunc:size`), never guessed.
6
+ //
7
+ // Response chunks are observed before fetch removes the content-encoding, so the body is
8
+ // passed as encoded bytes (`*_body_decoded = false`) and decoded by the sanitizer.
9
+ import diagnostics_channel from 'node:diagnostics_channel';
10
+ import { BodyTap } from './body.js';
11
+ import { hrNow, internal, netErrorOf, outboundTrace, withTtS } from './common.js';
12
+ import { als, newSpanId } from './context.js';
13
+ import { UNREAD_BODY_TIMEOUT_MS } from './outbound-http.js';
14
+ const EMPTY = Buffer.alloc(0);
15
+ const states = new WeakMap();
16
+ /** undici headers (flat array of Buffer/string, or a header block string) as flat latin1 [name, value, ...] strings. */
17
+ function headerFlat(h) {
18
+ const out = [];
19
+ if (Array.isArray(h)) {
20
+ for (let i = 0; i + 1 < h.length; i += 2) {
21
+ const k = h[i];
22
+ const v = h[i + 1];
23
+ const name = Buffer.isBuffer(k) ? k.toString('latin1') : String(k);
24
+ if (Array.isArray(v))
25
+ for (const one of v)
26
+ out.push(name, Buffer.isBuffer(one) ? one.toString('latin1') : String(one));
27
+ else
28
+ out.push(name, Buffer.isBuffer(v) ? v.toString('latin1') : String(v));
29
+ }
30
+ }
31
+ else if (typeof h === 'string') {
32
+ for (const line of h.split('\r\n')) {
33
+ const c = line.indexOf(':');
34
+ if (c > 0)
35
+ out.push(line.slice(0, c), line.slice(c + 1));
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ function hasHeader(h, lower) {
41
+ const f = headerFlat(h);
42
+ for (let i = 0; i + 1 < f.length; i += 2)
43
+ if (f[i].toLowerCase() === lower)
44
+ return f[i + 1];
45
+ return undefined;
46
+ }
47
+ export function installOutboundUndici(host) {
48
+ const subs = [];
49
+ const on = (name, fn) => {
50
+ const h = (m) => {
51
+ try {
52
+ fn(m);
53
+ }
54
+ catch {
55
+ host.noteError('capture_undici');
56
+ }
57
+ };
58
+ diagnostics_channel.subscribe(name, h);
59
+ subs.push([name, h]);
60
+ };
61
+ // Whether this undici publishes body chunks is learnt from the first chunk seen.
62
+ const seen = { sent: false, received: false };
63
+ on('undici:request:create', ({ request }) => {
64
+ if (internal.depth > 0)
65
+ return;
66
+ const t = hrNow();
67
+ const san = host.active();
68
+ if (!san)
69
+ return;
70
+ host.noteSeen();
71
+ const ctx = als.getStore();
72
+ if (ctx !== undefined && !ctx.sampled && !host.propagates()) {
73
+ host.noteOverhead(hrNow() - t);
74
+ return;
75
+ }
76
+ const origin = String(request.origin ?? '');
77
+ const tr = outboundTrace(ctx);
78
+ const decision = san.decide({ dir: 'outbound', req_headers: [], trace_id: tr.samplingTraceId, inherited: ctx?.sampled });
79
+ if (host.mayPropagate(origin) && typeof request.addHeader === 'function') {
80
+ const prev = hasHeader(request.headers, 'baggage');
81
+ if (prev === undefined)
82
+ request.addHeader('baggage', withTtS(undefined, decision.sampled));
83
+ else
84
+ request.addHeader('baggage', 'tt.s=' + (decision.sampled ? '1' : '0'));
85
+ }
86
+ if (!decision.sampled) {
87
+ host.noteOverhead(hrNow() - t);
88
+ return;
89
+ }
90
+ host.noteSampled();
91
+ const limit = san.policy.captureLimit; // max(max_body_bytes, bodies.max_large_body_bytes) + 1 (sanitizer.md 9.0, 22.5)
92
+ const st = {
93
+ san,
94
+ decision,
95
+ urlBase: origin,
96
+ method: String(request.method ?? 'GET'),
97
+ path: String(request.path ?? '/'),
98
+ traceId: tr.traceId,
99
+ spanId: newSpanId(),
100
+ parentSpanId: tr.parentSpanId,
101
+ t0: performance.now(),
102
+ tsMs: Date.now(),
103
+ reqTap: new BodyTap(limit),
104
+ reqBodyKnown: false,
105
+ resTap: new BodyTap(limit),
106
+ done: false,
107
+ overheadNs: 0,
108
+ };
109
+ const b = request.body;
110
+ if (b === null || b === undefined) {
111
+ st.reqBodyKnown = true;
112
+ }
113
+ else if (typeof b === 'string' || b instanceof Uint8Array) {
114
+ st.reqTap.add(b);
115
+ st.reqBodyKnown = true;
116
+ }
117
+ // Streamed bodies are counted by bodyChunkSent, when this undici publishes it.
118
+ states.set(request, st);
119
+ st.overheadNs = hrNow() - t;
120
+ });
121
+ on('undici:request:bodyChunkSent', ({ request, chunk }) => {
122
+ seen.sent = true;
123
+ const st = states.get(request);
124
+ if (!st)
125
+ return;
126
+ const b = request.body;
127
+ if (typeof b === 'string' || b instanceof Uint8Array)
128
+ return; // already taken from the request object
129
+ st.reqTap.add(chunk);
130
+ });
131
+ on('undici:request:headers', ({ request, response }) => {
132
+ const st = states.get(request);
133
+ if (!st)
134
+ return;
135
+ const r = response;
136
+ st.status = typeof r.statusCode === 'number' ? r.statusCode : undefined;
137
+ st.respHeaders = headerFlat(r.headers);
138
+ st.reqHeaders = headerFlat(request.headers);
139
+ st.timer = setTimeout(() => complete(host, request, false), UNREAD_BODY_TIMEOUT_MS);
140
+ st.timer.unref();
141
+ });
142
+ on('undici:request:bodyChunkReceived', ({ request, chunk }) => {
143
+ seen.received = true;
144
+ const st = states.get(request);
145
+ if (st)
146
+ st.resTap.add(chunk);
147
+ });
148
+ on('undici:request:trailers', ({ request }) => {
149
+ const st = states.get(request);
150
+ if (!st)
151
+ return;
152
+ st.resTap.ended = true;
153
+ complete(host, request, true);
154
+ });
155
+ on('undici:request:error', ({ request, error }) => {
156
+ const st = states.get(request);
157
+ if (!st)
158
+ return;
159
+ if (st.status === undefined)
160
+ st.netError = netErrorOf(error);
161
+ complete(host, request, false);
162
+ });
163
+ // Without the received-chunk channel the response body is never visible: a response is
164
+ // complete only if chunks were seen, the channel is known to work, or it declares no body.
165
+ const respBodyVisible = (st) => {
166
+ if (st.resTap.total > 0 || seen.received)
167
+ return true;
168
+ const rh = st.respHeaders ?? [];
169
+ for (let i = 0; i + 1 < rh.length; i += 2)
170
+ if (rh[i].toLowerCase() === 'content-length')
171
+ return rh[i + 1].trim() === '0';
172
+ return false;
173
+ };
174
+ function complete(h, request, ended) {
175
+ const st = states.get(request);
176
+ if (!st || st.done)
177
+ return;
178
+ const t = hrNow();
179
+ st.done = true;
180
+ states.delete(request);
181
+ if (st.timer)
182
+ clearTimeout(st.timer);
183
+ try {
184
+ const latencyMs = Math.max(0, Math.floor(performance.now() - st.t0));
185
+ const netError = st.status === undefined ? st.netError ?? 'other' : undefined;
186
+ const consumed = ended && respBodyVisible(st);
187
+ if (!st.reqBodyKnown && (st.reqTap.total > 0 || seen.sent))
188
+ st.reqBodyKnown = true;
189
+ const reqHeaders = st.reqHeaders ?? headerFlat(request.headers);
190
+ const reqBody = st.reqTap.bytes();
191
+ const resBody = consumed ? st.resTap.bytes() : EMPTY;
192
+ const wire = {
193
+ dir: 'outbound',
194
+ url_base: st.urlBase,
195
+ method: st.method,
196
+ target: st.path,
197
+ inherited: st.decision.by === 'inherited' ? st.decision.sampled : undefined,
198
+ req_headers: reqHeaders,
199
+ req_body: reqBody,
200
+ req_body_total: st.reqBodyKnown ? st.reqTap.total : 'unknown',
201
+ };
202
+ if (netError) {
203
+ wire.net_error = netError;
204
+ }
205
+ else {
206
+ wire.status = st.status;
207
+ wire.resp_headers = st.respHeaders ?? [];
208
+ wire.resp_body = resBody;
209
+ wire.resp_body_total = st.resTap.total;
210
+ wire.resp_body_consumed = consumed;
211
+ }
212
+ h.submit({
213
+ sanitizer: st.san,
214
+ decision: st.decision,
215
+ dir: 'outbound',
216
+ tsMs: st.tsMs,
217
+ latencyMs,
218
+ traceId: st.traceId,
219
+ spanId: st.spanId,
220
+ parentSpanId: st.parentSpanId,
221
+ status: st.status,
222
+ netError,
223
+ heldBytes: reqBody.length + resBody.length,
224
+ wire,
225
+ });
226
+ }
227
+ catch {
228
+ h.noteError('capture_undici_complete');
229
+ }
230
+ h.noteOverhead(st.overheadNs + (hrNow() - t));
231
+ }
232
+ return () => {
233
+ for (const [name, fn] of subs)
234
+ diagnostics_channel.unsubscribe(name, fn);
235
+ };
236
+ }
@@ -0,0 +1,27 @@
1
+ import type { Exchange, NetError } from '../sanitizer/sanitizer.js';
2
+ /** Flat [name, value, name, value ...] latin1 strings, or a serialized HTTP header block. */
3
+ export type WireHeaders = string[] | string;
4
+ export interface WireExchange {
5
+ dir: 'inbound' | 'outbound';
6
+ service_name?: string;
7
+ url_base?: string;
8
+ method: string;
9
+ /** Request target as received/sent (path and optional query). */
10
+ target: string;
11
+ framework?: string;
12
+ framework_route?: string;
13
+ trace_id?: string;
14
+ inherited?: boolean;
15
+ req_headers: WireHeaders;
16
+ req_body: Uint8Array;
17
+ req_body_total?: number | 'unknown';
18
+ req_body_consumed?: boolean;
19
+ status?: number;
20
+ net_error?: NetError;
21
+ resp_headers?: WireHeaders;
22
+ resp_body?: Uint8Array;
23
+ resp_body_total?: number | 'unknown';
24
+ resp_body_consumed?: boolean;
25
+ }
26
+ /** Wire exchange to the sanitizer's input (sanitizer.md 1). */
27
+ export declare function wireToExchange(w: WireExchange): Exchange;
@@ -0,0 +1,65 @@
1
+ // Wire form of a captured exchange: plain strings, numbers and byte arrays only, so it can
2
+ // be posted to a sanitizer worker thread (structured clone) or converted in place.
3
+ //
4
+ // The capture hooks fill this in with the least possible work (they keep references to the
5
+ // strings Node already built); the conversion to the sanitizer's `Exchange` (header
6
+ // buffers, request-target split, header-block parsing) happens later, off the request
7
+ // path, and on the worker thread when one is used.
8
+ import { parseHeaderBlock, splitTarget } from './common.js';
9
+ function pairs(h) {
10
+ if (h === undefined)
11
+ return [];
12
+ if (typeof h === 'string')
13
+ return parseHeaderBlock(h);
14
+ const out = [];
15
+ for (let i = 0; i + 1 < h.length; i += 2)
16
+ out.push([h[i], Buffer.from(h[i + 1], 'latin1')]);
17
+ return out;
18
+ }
19
+ /** Zero-copy view of body bytes that arrived through a message port or the tap. */
20
+ function asBuffer(b) {
21
+ if (b === undefined)
22
+ return undefined;
23
+ return Buffer.isBuffer(b) ? b : Buffer.from(b.buffer, b.byteOffset, b.byteLength);
24
+ }
25
+ /** Wire exchange to the sanitizer's input (sanitizer.md 1). */
26
+ export function wireToExchange(w) {
27
+ const { path, query } = splitTarget(w.target);
28
+ const ex = {
29
+ dir: w.dir,
30
+ method: w.method,
31
+ raw_path: path,
32
+ raw_query: query,
33
+ req_headers: pairs(w.req_headers),
34
+ req_body: asBuffer(w.req_body),
35
+ };
36
+ if (w.service_name !== undefined)
37
+ ex.service_name = w.service_name;
38
+ if (w.url_base !== undefined)
39
+ ex.url_base = w.url_base;
40
+ if (w.framework_route !== undefined) {
41
+ ex.framework = w.framework ?? 'express';
42
+ ex.framework_route = w.framework_route;
43
+ }
44
+ if (w.trace_id !== undefined)
45
+ ex.trace_id = w.trace_id;
46
+ if (w.inherited !== undefined)
47
+ ex.inherited = w.inherited;
48
+ if (w.req_body_total !== undefined)
49
+ ex.req_body_total = w.req_body_total;
50
+ if (w.req_body_consumed !== undefined)
51
+ ex.req_body_consumed = w.req_body_consumed;
52
+ if (w.net_error) {
53
+ ex.net_error = w.net_error;
54
+ }
55
+ else {
56
+ ex.status = w.status;
57
+ ex.resp_headers = pairs(w.resp_headers);
58
+ ex.resp_body = asBuffer(w.resp_body);
59
+ if (w.resp_body_total !== undefined)
60
+ ex.resp_body_total = w.resp_body_total;
61
+ if (w.resp_body_consumed !== undefined)
62
+ ex.resp_body_consumed = w.resp_body_consumed;
63
+ }
64
+ return ex;
65
+ }
@@ -0,0 +1,46 @@
1
+ import { type OMap } from '../sanitizer/json.js';
2
+ import { type NetError } from '../sanitizer/sanitizer.js';
3
+ export interface EventMeta {
4
+ policyId: string;
5
+ policyVersion: number;
6
+ policyHash: string;
7
+ sourceId: string;
8
+ dir: 'inbound' | 'outbound';
9
+ tsMs: number;
10
+ latencyMs: number;
11
+ status?: number;
12
+ netError?: NetError;
13
+ traceId?: string;
14
+ spanId?: string;
15
+ parentSpanId?: string;
16
+ }
17
+ /**
18
+ * Full canonical event from the sanitizer output (a map or its serialized top-level members)
19
+ * and the fields the source fills. Null if it exceeds 512 KiB (counted as sanitizer error).
20
+ */
21
+ export declare function buildEvent(out: OMap | Map<string, string>, m: EventMeta): string | null;
22
+ /** Marks a map of already serialized members (Result.parts) for buildEvent. */
23
+ export declare function serializedParts(parts: Map<string, string>): Map<string, string>;
24
+ export interface QueuedEvent {
25
+ /** Canonical event JSON. Empty when `record` already carries it (worker pipeline). */
26
+ json: string;
27
+ tsMs: number;
28
+ traceId?: string;
29
+ spanId?: string;
30
+ policyHash: string;
31
+ /** Ready-made OTLP logRecord (see makeRecord): the batch body is then only joined. */
32
+ record?: string;
33
+ /** Bytes charged to the export queue; defaults to json.length. */
34
+ size?: number;
35
+ }
36
+ export interface Resource {
37
+ serviceName: string;
38
+ sourceId: string;
39
+ }
40
+ /**
41
+ * OTLP/HTTP JSON logRecord for one event (event.md 4.2), written by hand: the canonical
42
+ * event is escaped once, here, and the batch body is a join of ready-made records.
43
+ */
44
+ export declare function makeRecord(e: QueuedEvent, observedMs: number): string;
45
+ /** OTLP/HTTP JSON body for a batch of events. */
46
+ export declare function otlpBody(events: QueuedEvent[], res: Resource, nowMs: number): string;
@@ -0,0 +1,85 @@
1
+ // Event assembly (event.md 1) and the OTLP/HTTP JSON logs body (event.md 4.2), written by hand.
2
+ import { CNum, writeCanonical } from '../sanitizer/json.js';
3
+ import { MAX_EVENT_BYTES, composeCanonical } from '../sanitizer/sanitizer.js';
4
+ import { SDK_VERSION, SOURCE_KIND } from '../version.js';
5
+ const HEX32 = /^[0-9a-f]{32}$/;
6
+ const HEX16 = /^[0-9a-f]{16}$/;
7
+ /**
8
+ * Full canonical event from the sanitizer output (a map or its serialized top-level members)
9
+ * and the fields the source fills. Null if it exceeds 512 KiB (counted as sanitizer error).
10
+ */
11
+ export function buildEvent(out, m) {
12
+ const parts = new Map();
13
+ const pre = isSerialized(out);
14
+ for (const [k, v] of out)
15
+ parts.set(k, pre ? v : writeCanonical(v));
16
+ const set = (k, v) => parts.set(k, writeCanonical(v));
17
+ set('schema', 'tt.http.exchange/v1');
18
+ set('policy', new Map([['id', m.policyId], ['version', new CNum(String(m.policyVersion))], ['hash', m.policyHash]]));
19
+ set('source', new Map([['kind', SOURCE_KIND], ['version', SDK_VERSION], ['id', m.sourceId]]));
20
+ set('ts', new Date(m.tsMs).toISOString());
21
+ set('latency_ms', new CNum(String(Math.max(0, Math.floor(m.latencyMs)))));
22
+ set('dir', m.dir);
23
+ if (m.netError) {
24
+ set('net_error', m.netError);
25
+ parts.delete('resp');
26
+ }
27
+ else if (typeof m.status === 'number' && m.status >= 100 && m.status <= 599) {
28
+ set('status', new CNum(String(m.status)));
29
+ }
30
+ else {
31
+ return null;
32
+ }
33
+ if (m.traceId && HEX32.test(m.traceId))
34
+ set('trace_id', m.traceId);
35
+ if (m.spanId && HEX16.test(m.spanId))
36
+ set('span_id', m.spanId);
37
+ if (m.parentSpanId && HEX16.test(m.parentSpanId))
38
+ set('parent_span_id', m.parentSpanId);
39
+ const s = composeCanonical(parts);
40
+ return s.length > MAX_EVENT_BYTES ? null : s;
41
+ }
42
+ const serialized = new WeakSet();
43
+ /** Marks a map of already serialized members (Result.parts) for buildEvent. */
44
+ export function serializedParts(parts) {
45
+ serialized.add(parts);
46
+ return parts;
47
+ }
48
+ function isSerialized(m) {
49
+ return serialized.has(m);
50
+ }
51
+ const attr = (key, value) => ({ key, value: { stringValue: value } });
52
+ const nanos = (ms) => (ms > 0 ? String(Math.trunc(ms)) + '000000' : '0');
53
+ /**
54
+ * OTLP/HTTP JSON logRecord for one event (event.md 4.2), written by hand: the canonical
55
+ * event is escaped once, here, and the batch body is a join of ready-made records.
56
+ */
57
+ export function makeRecord(e, observedMs) {
58
+ let r = '{"timeUnixNano":"' + nanos(e.tsMs) + '","observedTimeUnixNano":"' + nanos(observedMs)
59
+ + '","severityNumber":9,"severityText":"INFO","eventName":"tt.http.exchange"';
60
+ if (e.traceId && HEX32.test(e.traceId))
61
+ r += ',"traceId":"' + e.traceId + '"';
62
+ if (e.spanId && HEX16.test(e.spanId))
63
+ r += ',"spanId":"' + e.spanId + '"';
64
+ r += ',"attributes":[{"key":"tt.schema","value":{"stringValue":"tt.http.exchange/v1"}},{"key":"tt.policy.hash","value":{"stringValue":'
65
+ + JSON.stringify(e.policyHash) + '}}],"body":{"stringValue":' + JSON.stringify(e.json) + '}}';
66
+ return r;
67
+ }
68
+ /** OTLP/HTTP JSON body for a batch of events. */
69
+ export function otlpBody(events, res, nowMs) {
70
+ let recs = '';
71
+ for (let i = 0; i < events.length; i++) {
72
+ const e = events[i];
73
+ if (i > 0)
74
+ recs += ',';
75
+ recs += e.record ?? makeRecord(e, nowMs);
76
+ }
77
+ return '{"resourceLogs":[{"resource":' + JSON.stringify({
78
+ attributes: [
79
+ attr('service.name', res.serviceName),
80
+ attr('tt.source.kind', SOURCE_KIND),
81
+ attr('tt.source.version', SDK_VERSION),
82
+ attr('tt.source.id', res.sourceId),
83
+ ],
84
+ }) + ',"scopeLogs":[{"scope":' + JSON.stringify({ name: 'traffic-twin', version: SDK_VERSION }) + ',"logRecords":[' + recs + ']}]}]}';
85
+ }