@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,11 @@
1
+ export declare class BodyTap {
2
+ private readonly limit;
3
+ private chunks;
4
+ private kept;
5
+ total: number;
6
+ ended: boolean;
7
+ constructor(limit: number);
8
+ /** Records a chunk (Buffer, Uint8Array or string with encoding). Never throws. */
9
+ add(chunk: unknown, encoding?: unknown): void;
10
+ bytes(): Buffer;
11
+ }
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ // Body tee (sanitizer.md 9.0): keeps a copy of the first `limit` bytes and counts the rest.
3
+ // Never alters the chunks it sees; copies only what it keeps, once.
4
+ //
5
+ // Every kept chunk is a standalone allocation (its own ArrayBuffer, never a slice of the
6
+ // Buffer pool): the final bytes can then be handed to a worker thread without cloning a
7
+ // whole pool slab. The chunk list is bounded by `limit` bytes; a body made of one chunk
8
+ // (the common case) is returned without any further copy.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.BodyTap = void 0;
11
+ class BodyTap {
12
+ limit;
13
+ chunks = [];
14
+ kept = 0;
15
+ total = 0;
16
+ ended = false;
17
+ constructor(limit) {
18
+ this.limit = limit;
19
+ }
20
+ /** Records a chunk (Buffer, Uint8Array or string with encoding). Never throws. */
21
+ add(chunk, encoding) {
22
+ try {
23
+ if (chunk === null || chunk === undefined)
24
+ return;
25
+ if (typeof chunk === 'string') {
26
+ const enc = (typeof encoding === 'string' && Buffer.isEncoding(encoding) ? encoding : 'utf8');
27
+ const len = Buffer.byteLength(chunk, enc);
28
+ this.total += len;
29
+ if (this.kept < this.limit && len > 0) {
30
+ const room = this.limit - this.kept;
31
+ const b = Buffer.allocUnsafeSlow(len);
32
+ b.write(chunk, 0, len, enc);
33
+ const take = len > room ? b.subarray(0, room) : b;
34
+ this.chunks.push(take);
35
+ this.kept += take.length;
36
+ }
37
+ return;
38
+ }
39
+ if (chunk instanceof Uint8Array) {
40
+ const len = chunk.byteLength;
41
+ this.total += len;
42
+ if (this.kept < this.limit && len > 0) {
43
+ const take = Math.min(len, this.limit - this.kept);
44
+ // Copy: the app (or Node) may reuse the underlying memory.
45
+ const b = Buffer.allocUnsafeSlow(take);
46
+ b.set(take === len ? chunk : chunk.subarray(0, take));
47
+ this.chunks.push(b);
48
+ this.kept += take;
49
+ }
50
+ }
51
+ }
52
+ catch {
53
+ // Capturing must never affect the app.
54
+ }
55
+ }
56
+ bytes() {
57
+ const n = this.chunks.length;
58
+ if (n === 0)
59
+ return Buffer.alloc(0);
60
+ if (n === 1)
61
+ return this.chunks[0];
62
+ const b = Buffer.allocUnsafeSlow(this.kept);
63
+ let o = 0;
64
+ for (let i = 0; i < n; i++) {
65
+ const c = this.chunks[i];
66
+ b.set(c, o);
67
+ o += c.length;
68
+ }
69
+ this.chunks = [b];
70
+ return b;
71
+ }
72
+ }
73
+ exports.BodyTap = BodyTap;
@@ -0,0 +1,75 @@
1
+ import type { Decision, NetError, Sanitizer } from '../sanitizer/sanitizer.js';
2
+ import { ExchangeCtx } from './context.js';
3
+ import type { WireExchange, WireHeaders } from './wire.js';
4
+ /** A finished exchange, converted to sanitizer input lazily (off the request path, possibly in a worker thread). */
5
+ export interface RawExchange {
6
+ sanitizer: Sanitizer;
7
+ decision: Decision;
8
+ wire: WireExchange;
9
+ dir: 'inbound' | 'outbound';
10
+ tsMs: number;
11
+ latencyMs: number;
12
+ traceId?: string;
13
+ spanId?: string;
14
+ parentSpanId?: string;
15
+ status?: number;
16
+ netError?: NetError;
17
+ /** Bytes held by this exchange (queue accounting). */
18
+ heldBytes: number;
19
+ }
20
+ /** What the capture hooks need from the agent. Every method must be cheap and never throw. */
21
+ export interface CaptureHost {
22
+ /** Current sanitizer when capture is allowed, else null (fast path). */
23
+ active(): Sanitizer | null;
24
+ serviceName(): string;
25
+ /** Header names (lowercase) needed for the inbound sampling decision. */
26
+ decisionHeaders(): ReadonlySet<string>;
27
+ /** Whether tt.s may be added to the baggage of a call to this url base (sanitizer.md 12). */
28
+ mayPropagate(urlBase: string): boolean;
29
+ /** Whether the policy asks for tt.s on some outbound calls (else unsampled calls need no per-call work). */
30
+ propagates(): boolean;
31
+ submit(raw: RawExchange): void;
32
+ noteSeen(): void;
33
+ noteSampled(): void;
34
+ noteOverhead(ns: number): void;
35
+ noteError(code: string): void;
36
+ }
37
+ /** True while the SDK itself creates a request (exporter, Fleet): such requests are never captured. */
38
+ export declare const internal: {
39
+ depth: number;
40
+ };
41
+ /** HTTP agents owned by the SDK; requests through them are never captured. */
42
+ export declare const internalAgents: WeakSet<object>;
43
+ export declare function runInternal<T>(fn: () => T): T;
44
+ /** node:http rawHeaders (latin1 strings) to [name, bytes] pairs. */
45
+ export declare function rawPairs(raw: readonly string[] | undefined, only?: ReadonlySet<string>): Array<[string, Buffer]>;
46
+ export declare function rawHeader(raw: readonly string[] | undefined, lower: string): string | undefined;
47
+ /** Parses the serialized header block of an OutgoingMessage (`_header`). */
48
+ export declare function parseHeaderBlock(block: string): Array<[string, Buffer]>;
49
+ /** Headers of an OutgoingMessage (server response or client request) as sent on the wire. */
50
+ export declare function outgoingHeaders(msg: {
51
+ _header?: unknown;
52
+ getHeaders?: () => Record<string, unknown>;
53
+ }): Array<[string, Buffer]>;
54
+ /** Headers of an OutgoingMessage in wire form: the serialized block when Node has built it (parsed later), else a flat list. */
55
+ export declare function outgoingHeadersWire(msg: {
56
+ _header?: unknown;
57
+ getHeaders?: () => Record<string, unknown>;
58
+ }): WireHeaders;
59
+ /** Splits a request target into raw path and raw query bytes (query undefined without "?"). */
60
+ export declare function splitTarget(target: string): {
61
+ path: Buffer;
62
+ query: Buffer | undefined;
63
+ };
64
+ /** Maps a network error to the event enum (event.md). */
65
+ export declare function netErrorOf(err: unknown): NetError;
66
+ /** Trace data for an outbound call, from the inbound context or OpenTelemetry. */
67
+ export declare function outboundTrace(ctx: ExchangeCtx | undefined): {
68
+ traceId: string;
69
+ parentSpanId?: string;
70
+ samplingTraceId?: string;
71
+ };
72
+ /** Monotonic clock in nanoseconds as a double (no BigInt allocation on the request path). */
73
+ export declare function hrNow(): number;
74
+ /** Adds tt.s to a baggage header value without touching the other members. */
75
+ export declare function withTtS(existing: string | undefined, sampled: boolean): string;
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ // Shared pieces of the capture hooks.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.internalAgents = exports.internal = void 0;
5
+ exports.runInternal = runInternal;
6
+ exports.rawPairs = rawPairs;
7
+ exports.rawHeader = rawHeader;
8
+ exports.parseHeaderBlock = parseHeaderBlock;
9
+ exports.outgoingHeaders = outgoingHeaders;
10
+ exports.outgoingHeadersWire = outgoingHeadersWire;
11
+ exports.splitTarget = splitTarget;
12
+ exports.netErrorOf = netErrorOf;
13
+ exports.outboundTrace = outboundTrace;
14
+ exports.hrNow = hrNow;
15
+ exports.withTtS = withTtS;
16
+ const context_js_1 = require("./context.js");
17
+ /** True while the SDK itself creates a request (exporter, Fleet): such requests are never captured. */
18
+ exports.internal = { depth: 0 };
19
+ /** HTTP agents owned by the SDK; requests through them are never captured. */
20
+ exports.internalAgents = new WeakSet();
21
+ function runInternal(fn) {
22
+ exports.internal.depth++;
23
+ try {
24
+ return fn();
25
+ }
26
+ finally {
27
+ exports.internal.depth--;
28
+ }
29
+ }
30
+ const lengthsOf = new WeakMap();
31
+ /** node:http rawHeaders (latin1 strings) to [name, bytes] pairs. */
32
+ function rawPairs(raw, only) {
33
+ const out = [];
34
+ if (!raw)
35
+ return out;
36
+ let lens;
37
+ if (only) {
38
+ lens = lengthsOf.get(only);
39
+ if (!lens) {
40
+ lens = new Set();
41
+ for (const k of only)
42
+ lens.add(k.length);
43
+ lengthsOf.set(only, lens);
44
+ }
45
+ }
46
+ for (let i = 0; i + 1 < raw.length; i += 2) {
47
+ const n = raw[i];
48
+ // Most headers cannot match by length alone: skip the lowercase copy for them.
49
+ if (only && (!lens.has(n.length) || !only.has(n.toLowerCase())))
50
+ continue;
51
+ out.push([n, Buffer.from(raw[i + 1], 'latin1')]);
52
+ }
53
+ return out;
54
+ }
55
+ function rawHeader(raw, lower) {
56
+ if (!raw)
57
+ return undefined;
58
+ for (let i = 0; i + 1 < raw.length; i += 2)
59
+ if (raw[i].length === lower.length && raw[i].toLowerCase() === lower)
60
+ return raw[i + 1];
61
+ return undefined;
62
+ }
63
+ /** Parses the serialized header block of an OutgoingMessage (`_header`). */
64
+ function parseHeaderBlock(block) {
65
+ const out = [];
66
+ const lines = block.split('\r\n');
67
+ for (let i = 1; i < lines.length; i++) {
68
+ const l = lines[i];
69
+ if (l === '')
70
+ continue;
71
+ const c = l.indexOf(':');
72
+ if (c <= 0)
73
+ continue;
74
+ out.push([l.slice(0, c), Buffer.from(l.slice(c + 1), 'latin1')]);
75
+ }
76
+ return out;
77
+ }
78
+ /** Headers of an OutgoingMessage (server response or client request) as sent on the wire. */
79
+ function outgoingHeaders(msg) {
80
+ if (typeof msg._header === 'string' && msg._header.length > 0)
81
+ return parseHeaderBlock(msg._header);
82
+ const out = [];
83
+ try {
84
+ const h = msg.getHeaders ? msg.getHeaders() : {};
85
+ for (const k of Object.keys(h)) {
86
+ const v = h[k];
87
+ for (const one of Array.isArray(v) ? v : [v]) {
88
+ if (one === undefined || one === null)
89
+ continue;
90
+ out.push([k, Buffer.from(String(one), 'latin1')]);
91
+ }
92
+ }
93
+ }
94
+ catch {
95
+ // ignore
96
+ }
97
+ return out;
98
+ }
99
+ /** Headers of an OutgoingMessage in wire form: the serialized block when Node has built it (parsed later), else a flat list. */
100
+ function outgoingHeadersWire(msg) {
101
+ if (typeof msg._header === 'string' && msg._header.length > 0)
102
+ return msg._header;
103
+ const out = [];
104
+ try {
105
+ const h = msg.getHeaders ? msg.getHeaders() : {};
106
+ for (const k of Object.keys(h)) {
107
+ const v = h[k];
108
+ for (const one of Array.isArray(v) ? v : [v]) {
109
+ if (one === undefined || one === null)
110
+ continue;
111
+ out.push(k, String(one));
112
+ }
113
+ }
114
+ }
115
+ catch {
116
+ // ignore
117
+ }
118
+ return out;
119
+ }
120
+ /** Splits a request target into raw path and raw query bytes (query undefined without "?"). */
121
+ function splitTarget(target) {
122
+ let t = target;
123
+ const abs = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]*/.exec(t);
124
+ if (abs)
125
+ t = t.slice(abs[0].length) || '/';
126
+ const hash = t.indexOf('#');
127
+ if (hash >= 0)
128
+ t = t.slice(0, hash);
129
+ const q = t.indexOf('?');
130
+ if (q < 0)
131
+ return { path: Buffer.from(t, 'latin1'), query: undefined };
132
+ return { path: Buffer.from(t.slice(0, q), 'latin1'), query: Buffer.from(t.slice(q + 1), 'latin1') };
133
+ }
134
+ /** Maps a network error to the event enum (event.md). */
135
+ function netErrorOf(err) {
136
+ const e = err;
137
+ const code = typeof e?.code === 'string' ? e.code : '';
138
+ if (!code && e?.cause)
139
+ return netErrorOf(e.cause);
140
+ if (/TIMEOUT|ETIMEDOUT|ESOCKETTIMEDOUT/.test(code))
141
+ return 'timeout';
142
+ if (code === 'ECONNRESET' || code === 'EPIPE' || code === 'UND_ERR_SOCKET' || code === 'ECONNABORTED')
143
+ return 'reset';
144
+ if (code === 'ECONNREFUSED')
145
+ return 'refused';
146
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'EAI_NONAME' || code === 'EAI_FAIL')
147
+ return 'dns';
148
+ if (/^ERR_TLS|^ERR_SSL|CERT|UNABLE_TO_VERIFY|SELF_SIGNED|DEPTH_ZERO/.test(code))
149
+ return 'tls';
150
+ return 'other';
151
+ }
152
+ /** Trace data for an outbound call, from the inbound context or OpenTelemetry. */
153
+ function outboundTrace(ctx) {
154
+ if (ctx && !ctx.sampled)
155
+ return { traceId: '' }; // inherited: not sampled, no ids needed
156
+ const o = (0, context_js_1.otelActive)();
157
+ if (ctx) {
158
+ // Adopt the OpenTelemetry trace id when the inbound request carried none, so the
159
+ // inbound and outbound events share the app's own trace.
160
+ if (!ctx.traceKnown && o) {
161
+ ctx.traceId = o.traceId;
162
+ ctx.traceKnown = true;
163
+ }
164
+ return { traceId: ctx.traceId, parentSpanId: ctx.spanId };
165
+ }
166
+ if (o)
167
+ return { traceId: o.traceId, parentSpanId: o.spanId || undefined, samplingTraceId: o.traceId };
168
+ return { traceId: (0, context_js_1.newTraceId)() };
169
+ }
170
+ /** Monotonic clock in nanoseconds as a double (no BigInt allocation on the request path). */
171
+ function hrNow() {
172
+ return performance.now() * 1e6;
173
+ }
174
+ /** Adds tt.s to a baggage header value without touching the other members. */
175
+ function withTtS(existing, sampled) {
176
+ const member = 'tt.s=' + (sampled ? '1' : '0');
177
+ if (!existing || existing.trim() === '')
178
+ return member;
179
+ return existing + ',' + member;
180
+ }
@@ -0,0 +1,28 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ export interface ExchangeCtx {
3
+ sampled: boolean;
4
+ traceId: string;
5
+ /** true when traceId came from the request (traceparent) or from OpenTelemetry. */
6
+ traceKnown: boolean;
7
+ spanId: string;
8
+ }
9
+ export declare const als: AsyncLocalStorage<ExchangeCtx>;
10
+ /** Context of every unsampled exchange: nothing is recorded for it or for the calls it makes, so one shared frozen object serves. */
11
+ export declare const UNSAMPLED: ExchangeCtx;
12
+ export declare const newTraceId: () => string;
13
+ export declare const newSpanId: () => string;
14
+ /** W3C trace context: returns ids from a traceparent value, or null if invalid. */
15
+ export declare function parseTraceparent(v: string | undefined | null): {
16
+ traceId: string;
17
+ spanId: string;
18
+ } | null;
19
+ /**
20
+ * Loads @opentelemetry/api from the application's dependency tree, if present. Loading the
21
+ * API package has no side effect; the SDK only calls read-only functions on it.
22
+ */
23
+ export declare function loadOtelApi(): boolean;
24
+ /** Active OpenTelemetry span context, if the app has one. Never throws. */
25
+ export declare function otelActive(): {
26
+ traceId: string;
27
+ spanId: string;
28
+ } | null;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ // Exchange context (event.md 1.1, sanitizer.md 12, ARCHITECTURE 3.2).
3
+ //
4
+ // An AsyncLocalStorage store binds outbound calls to the inbound exchange being served:
5
+ // same trace_id, inherited sampling decision. W3C traceparent is read from the inbound
6
+ // request. If the app uses OpenTelemetry, the active span context is only READ through
7
+ // @opentelemetry/api resolved from the app; this module never registers a provider,
8
+ // propagator or context manager and never injects traceparent.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.newSpanId = exports.newTraceId = exports.UNSAMPLED = exports.als = void 0;
11
+ exports.parseTraceparent = parseTraceparent;
12
+ exports.loadOtelApi = loadOtelApi;
13
+ exports.otelActive = otelActive;
14
+ const node_async_hooks_1 = require("node:async_hooks");
15
+ const node_crypto_1 = require("node:crypto");
16
+ const node_module_1 = require("node:module");
17
+ const node_path_1 = require("node:path");
18
+ exports.als = new node_async_hooks_1.AsyncLocalStorage();
19
+ /** Context of every unsampled exchange: nothing is recorded for it or for the calls it makes, so one shared frozen object serves. */
20
+ exports.UNSAMPLED = Object.freeze({ sampled: false, traceId: '', traceKnown: false, spanId: '' });
21
+ // Ids come from a pooled CSPRNG buffer: one system call per 256 ids instead of one per id.
22
+ const pool = Buffer.alloc(4096);
23
+ let poolOff = pool.length;
24
+ function nonZeroHex(bytes) {
25
+ for (;;) {
26
+ if (poolOff + bytes > pool.length) {
27
+ (0, node_crypto_1.randomFillSync)(pool);
28
+ poolOff = 0;
29
+ }
30
+ const h = pool.toString('hex', poolOff, poolOff + bytes);
31
+ poolOff += bytes;
32
+ if (!/^0+$/.test(h))
33
+ return h;
34
+ }
35
+ }
36
+ const newTraceId = () => nonZeroHex(16);
37
+ exports.newTraceId = newTraceId;
38
+ const newSpanId = () => nonZeroHex(8);
39
+ exports.newSpanId = newSpanId;
40
+ const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?$/;
41
+ /** W3C trace context: returns ids from a traceparent value, or null if invalid. */
42
+ function parseTraceparent(v) {
43
+ if (typeof v !== 'string')
44
+ return null;
45
+ const m = TRACEPARENT.exec(v.trim());
46
+ if (!m)
47
+ return null;
48
+ if (m[1] === 'ff' || (m[1] === '00' && m[5] !== undefined))
49
+ return null;
50
+ if (/^0+$/.test(m[2]) || /^0+$/.test(m[3]))
51
+ return null;
52
+ return { traceId: m[2], spanId: m[3] };
53
+ }
54
+ let otel;
55
+ /**
56
+ * Loads @opentelemetry/api from the application's dependency tree, if present. Loading the
57
+ * API package has no side effect; the SDK only calls read-only functions on it.
58
+ */
59
+ function loadOtelApi() {
60
+ if (otel !== undefined)
61
+ return otel !== null;
62
+ otel = null;
63
+ try {
64
+ const req = (0, node_module_1.createRequire)((0, node_path_1.join)(process.cwd(), '__traffic_twin_resolve__.js'));
65
+ const mod = req(req.resolve('@opentelemetry/api'));
66
+ const api = mod.trace ? mod : mod.default;
67
+ if (api && api.trace && typeof api.trace.getSpanContext === 'function' && api.context)
68
+ otel = api;
69
+ }
70
+ catch {
71
+ otel = null;
72
+ }
73
+ return otel !== null;
74
+ }
75
+ /** Active OpenTelemetry span context, if the app has one. Never throws. */
76
+ function otelActive() {
77
+ if (!otel)
78
+ return null;
79
+ try {
80
+ const sc = otel.trace.getSpanContext(otel.context.active());
81
+ if (!sc)
82
+ return null;
83
+ if (otel.isSpanContextValid && !otel.isSpanContextValid(sc))
84
+ return null;
85
+ if (!/^[0-9a-f]{32}$/.test(sc.traceId) || /^0+$/.test(sc.traceId))
86
+ return null;
87
+ return { traceId: sc.traceId, spanId: /^[0-9a-f]{16}$/.test(sc.spanId) ? sc.spanId : '' };
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
@@ -0,0 +1,12 @@
1
+ import { BodyTap } from './body.js';
2
+ import { type CaptureHost } from './common.js';
3
+ /** Wraps `push` of an IncomingMessage to tee body bytes (de-chunked by the parser). */
4
+ export declare function tapPush(stream: {
5
+ push: (chunk: unknown, enc?: unknown) => boolean;
6
+ }, tap: BodyTap): void;
7
+ /** Wraps write/end of an OutgoingMessage to tee body bytes. */
8
+ export declare function tapWrites(msg: {
9
+ write: (...a: unknown[]) => boolean;
10
+ end: (...a: unknown[]) => unknown;
11
+ }, tap: BodyTap, onEnd?: () => void): void;
12
+ export declare function installInbound(host: CaptureHost): () => void;
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ // Inbound capture for node:http / node:https servers through diagnostics_channel
3
+ // (`http.server.request.start`, `http.server.response.finish`). No global patching: the
4
+ // only wrappers are installed on the request/response objects of sampled exchanges, they
5
+ // are synchronous and they never change arguments, return values or timing.
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.tapPush = tapPush;
11
+ exports.tapWrites = tapWrites;
12
+ exports.installInbound = installInbound;
13
+ const node_diagnostics_channel_1 = __importDefault(require("node:diagnostics_channel"));
14
+ const body_js_1 = require("./body.js");
15
+ const common_js_1 = require("./common.js");
16
+ const context_js_1 = require("./context.js");
17
+ const EMPTY = Buffer.alloc(0);
18
+ const kState = Symbol('traffic-twin.inbound');
19
+ /** Wraps `push` of an IncomingMessage to tee body bytes (de-chunked by the parser). */
20
+ function tapPush(stream, tap) {
21
+ const orig = stream.push;
22
+ stream.push = function push(chunk, enc) {
23
+ if (chunk === null)
24
+ tap.ended = true;
25
+ else
26
+ tap.add(chunk, enc);
27
+ // eslint-disable-next-line prefer-rest-params
28
+ return orig.apply(this, arguments);
29
+ };
30
+ }
31
+ /** Wraps write/end of an OutgoingMessage to tee body bytes. */
32
+ function tapWrites(msg, tap, onEnd) {
33
+ const ow = msg.write;
34
+ const oe = msg.end;
35
+ msg.write = function write(chunk, enc) {
36
+ if (typeof chunk !== 'function')
37
+ tap.add(chunk, typeof enc === 'string' ? enc : undefined);
38
+ // eslint-disable-next-line prefer-rest-params
39
+ return ow.apply(this, arguments);
40
+ };
41
+ msg.end = function end(chunk, enc) {
42
+ if (typeof chunk !== 'function')
43
+ tap.add(chunk, typeof enc === 'string' ? enc : undefined);
44
+ tap.ended = true;
45
+ if (onEnd) {
46
+ try {
47
+ onEnd();
48
+ }
49
+ catch {
50
+ // ignore
51
+ }
52
+ }
53
+ // eslint-disable-next-line prefer-rest-params
54
+ return oe.apply(this, arguments);
55
+ };
56
+ }
57
+ function installInbound(host) {
58
+ const onStart = (message) => {
59
+ const t = (0, common_js_1.hrNow)();
60
+ const { request, response } = message;
61
+ try {
62
+ start(host, request, response, t);
63
+ }
64
+ catch {
65
+ host.noteError('capture_inbound_start');
66
+ }
67
+ };
68
+ const onFinish = (message) => {
69
+ const { request, response } = message;
70
+ // Unsampled exchanges (the vast majority at low rates) carry no state: nothing to do.
71
+ if (response[kState] === undefined)
72
+ return;
73
+ const t = (0, common_js_1.hrNow)();
74
+ try {
75
+ finish(host, request, response, t);
76
+ }
77
+ catch {
78
+ host.noteError('capture_inbound_finish');
79
+ }
80
+ };
81
+ node_diagnostics_channel_1.default.subscribe('http.server.request.start', onStart);
82
+ node_diagnostics_channel_1.default.subscribe('http.server.response.finish', onFinish);
83
+ return () => {
84
+ node_diagnostics_channel_1.default.unsubscribe('http.server.request.start', onStart);
85
+ node_diagnostics_channel_1.default.unsubscribe('http.server.response.finish', onFinish);
86
+ };
87
+ }
88
+ function start(host, req, res, t) {
89
+ const san = host.active();
90
+ if (!san)
91
+ return;
92
+ host.noteSeen();
93
+ const raw = req.rawHeaders;
94
+ const tph = (0, common_js_1.rawHeader)(raw, 'traceparent');
95
+ const tp = tph === undefined ? null : (0, context_js_1.parseTraceparent)(tph);
96
+ const service = host.serviceName();
97
+ const decision = san.decideRaw(service, raw, tp?.traceId) ?? san.decide({
98
+ dir: 'inbound',
99
+ service_name: service,
100
+ req_headers: (0, common_js_1.rawPairs)(raw, host.decisionHeaders()),
101
+ trace_id: tp?.traceId,
102
+ });
103
+ if (!decision.sampled) {
104
+ // Nothing is recorded for this exchange or for the calls it makes (their decision is
105
+ // inherited): no tees, no wrappers, no ids; only the shared "not sampled" context.
106
+ context_js_1.als.enterWith(context_js_1.UNSAMPLED);
107
+ host.noteOverhead((0, common_js_1.hrNow)() - t);
108
+ return;
109
+ }
110
+ const ctx = { sampled: true, traceId: tp?.traceId ?? (0, context_js_1.newTraceId)(), traceKnown: !!tp, spanId: (0, context_js_1.newSpanId)() };
111
+ // Bind the rest of this request's async work (handlers, outbound calls) to ctx.
112
+ context_js_1.als.enterWith(ctx);
113
+ host.noteSampled();
114
+ const limit = san.policy.captureLimit; // max(max_body_bytes, bodies.max_large_body_bytes) + 1 (sanitizer.md 9.0, 22.5)
115
+ const st = {
116
+ san,
117
+ decision,
118
+ ctx,
119
+ parentSpanId: tp?.spanId,
120
+ t0: performance.now(),
121
+ tsMs: Date.now(),
122
+ reqTap: new body_js_1.BodyTap(limit),
123
+ resTap: new body_js_1.BodyTap(limit),
124
+ overheadNs: 0,
125
+ };
126
+ tapPush(req, st.reqTap);
127
+ // res.end() runs inside the handler, where an OpenTelemetry server span (if any) is active:
128
+ // adopt its trace id when the request carried none, so the event joins the app's trace.
129
+ tapWrites(res, st.resTap, () => {
130
+ if (st.ctx.traceKnown)
131
+ return;
132
+ const o = (0, context_js_1.otelActive)();
133
+ if (o) {
134
+ st.ctx.traceId = o.traceId;
135
+ st.ctx.traceKnown = true;
136
+ }
137
+ });
138
+ res[kState] = st;
139
+ st.overheadNs = (0, common_js_1.hrNow)() - t;
140
+ }
141
+ function finish(host, req, res, t) {
142
+ const st = res[kState];
143
+ if (!st)
144
+ return;
145
+ res[kState] = undefined;
146
+ const latencyMs = Math.max(0, Math.floor(performance.now() - st.t0));
147
+ // 9.0: a body the app did not read to the end is "not consumed"; an empty body is complete.
148
+ const reqConsumed = req.readableEnded || (req.complete && st.reqTap.total === 0);
149
+ const status = res.statusCode;
150
+ const method = req.method ?? '';
151
+ const noBody = method === 'HEAD' || status === 204 || status === 304 || (status >= 100 && status < 200);
152
+ const target = req.url ?? '/';
153
+ const respHeaders = (0, common_js_1.outgoingHeadersWire)(res);
154
+ // Express: only routes defined directly on the app carry their complete template.
155
+ let route;
156
+ if (req.route && typeof req.route.path === 'string' && (req.baseUrl === '' || req.baseUrl === undefined))
157
+ route = req.route.path;
158
+ const reqBody = reqConsumed ? st.reqTap.bytes() : EMPTY;
159
+ const resBody = noBody ? EMPTY : st.resTap.bytes();
160
+ const resTotal = noBody ? 0 : st.resTap.total;
161
+ const resEnded = noBody || st.resTap.ended;
162
+ const wire = {
163
+ dir: 'inbound',
164
+ service_name: host.serviceName(),
165
+ method,
166
+ target,
167
+ trace_id: st.ctx.traceKnown ? st.ctx.traceId : undefined,
168
+ req_headers: req.rawHeaders,
169
+ req_body: reqBody,
170
+ req_body_total: st.reqTap.total,
171
+ req_body_consumed: reqConsumed,
172
+ status,
173
+ resp_headers: respHeaders,
174
+ resp_body: resBody,
175
+ resp_body_total: resEnded ? resTotal : 'unknown',
176
+ };
177
+ if (route !== undefined) {
178
+ wire.framework = 'express';
179
+ wire.framework_route = route;
180
+ }
181
+ host.submit({
182
+ sanitizer: st.san,
183
+ decision: st.decision,
184
+ dir: 'inbound',
185
+ tsMs: st.tsMs,
186
+ latencyMs,
187
+ traceId: st.ctx.traceId,
188
+ spanId: st.ctx.spanId,
189
+ parentSpanId: st.parentSpanId,
190
+ status,
191
+ heldBytes: reqBody.length + resBody.length,
192
+ wire,
193
+ });
194
+ host.noteOverhead(st.overheadNs + ((0, common_js_1.hrNow)() - t));
195
+ }
@@ -0,0 +1,4 @@
1
+ import { type CaptureHost } from './common.js';
2
+ /** How long an unread response body is waited for before the exchange is closed as not consumed. */
3
+ export declare const UNREAD_BODY_TIMEOUT_MS = 60000;
4
+ export declare function installOutboundHttp(host: CaptureHost): () => void;