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