@ciphyrshq/sdk 2.6.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/package.json +4 -2
- package/src/client.js +326 -20
- package/src/client.test.js +99 -0
- package/src/context.js +82 -0
- package/src/fail-posture.js +100 -0
- package/src/fail-posture.test.js +385 -0
- package/src/index.js +14 -0
- package/src/no-network.test-helper.js +108 -0
- package/src/propagation.js +388 -0
- package/src/propagation.test.js +429 -0
- package/src/protect-tool.js +362 -0
- package/src/protect-tool.test.js +446 -0
- package/src/secret-detector.js +21 -3
- package/src/secret-detector.test.js +155 -0
- package/src/tracer.js +278 -9
- package/src/tracer.test.js +194 -0
- package/types.d.ts +265 -5
package/src/index.js
CHANGED
|
@@ -9,5 +9,19 @@ export {
|
|
|
9
9
|
CiphyrsJobTimeoutError,
|
|
10
10
|
} from './errors.js';
|
|
11
11
|
export { CiphyrsTracer, Trace, Span } from './tracer.js';
|
|
12
|
+
// 2.7 — cross-process trace propagation: what makes agents in separate
|
|
13
|
+
// processes appear as one connected topology instead of isolated nodes.
|
|
14
|
+
export {
|
|
15
|
+
inject, extract, withRemoteContext, currentContext,
|
|
16
|
+
instrumentFetch, instrumentHttp, autoInstrument,
|
|
17
|
+
expressMiddleware, fastifyPlugin, withPropagation,
|
|
18
|
+
w3cTraceId, w3cSpanId, TRACEPARENT, BAGGAGE,
|
|
19
|
+
} from './propagation.js';
|
|
20
|
+
// activeSpanKind is exported alongside the ids because "which span am I in?"
|
|
21
|
+
// is not answerable from the ids alone, and the answer decides whether a span
|
|
22
|
+
// id may be reported to the tool gate at all — see protect-tool.js.
|
|
23
|
+
export { activeIds, activeAgent, activeSpanKind, remoteParent } from './context.js';
|
|
12
24
|
export { SecretDetector } from './secret-detector.js';
|
|
13
25
|
export { EvalRunner } from './eval-runner.js';
|
|
26
|
+
// V60 — agentic governance: tool-call authorization
|
|
27
|
+
export { protectTool, ToolBlocked, ToolApprovalTimeout } from './protect-tool.js';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// The test suite may not open a socket. Enforced here, not by convention.
|
|
3
|
+
//
|
|
4
|
+
// WHY. fail-posture.test.js built a real CiphyrsClient — whose baseUrl
|
|
5
|
+
// defaults to https://www.ciphyrs.com — and wrapped tools with protectTool,
|
|
6
|
+
// which queues a tool-inventory announce on a 2s debounce. Every `npm test`
|
|
7
|
+
// run therefore made two real POSTs to the PRODUCTION announce endpoint with
|
|
8
|
+
// a fake API key, and the last test in that file swapped global fetch and
|
|
9
|
+
// restored it, so the debounce fired on the real fetch after the restore. No
|
|
10
|
+
// amount of stubbing in the right order fixes that: the announce is timed,
|
|
11
|
+
// and the test that "protects" it has already finished.
|
|
12
|
+
//
|
|
13
|
+
// So the guard sits BELOW fetch, at the socket, and is installed when this
|
|
14
|
+
// module is imported — before any test body runs. Whatever a test does to
|
|
15
|
+
// globalThis.fetch, whichever library it reaches for, and whenever a stray
|
|
16
|
+
// timer fires, the connection does not happen and the run fails.
|
|
17
|
+
//
|
|
18
|
+
// Import it for side effect at the top of every test file:
|
|
19
|
+
//
|
|
20
|
+
// import './no-network.test-helper.js'
|
|
21
|
+
//
|
|
22
|
+
// `node --test` runs each test file in its own process, so each file needs
|
|
23
|
+
// its own import; a file without one is unguarded.
|
|
24
|
+
//
|
|
25
|
+
// Not part of the published API. It lives in src/ because src/*.test.js does,
|
|
26
|
+
// and it is deliberately NOT named *.test.js so the runner does not treat it
|
|
27
|
+
// as a suite of its own.
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
import net from 'node:net'
|
|
30
|
+
import tls from 'node:tls'
|
|
31
|
+
|
|
32
|
+
/** Every blocked attempt, in order. Emptied only by expectBlocked(). */
|
|
33
|
+
const attempts = []
|
|
34
|
+
|
|
35
|
+
// net.connect() normalises its arguments before calling Socket.prototype.connect,
|
|
36
|
+
// so what arrives is `[[{ host, port }, cb]]` for a URL-shaped dial and
|
|
37
|
+
// `(port, host)` for a direct socket.connect — both are unwrapped here so the
|
|
38
|
+
// failure message names the host a test was reaching for.
|
|
39
|
+
const describeTarget = (arg, second) => {
|
|
40
|
+
if (Array.isArray(arg)) return describeTarget(arg[0], arg[1])
|
|
41
|
+
if (arg && typeof arg === 'object') return `${arg.host || arg.path || '?'}:${arg.port ?? '?'}`
|
|
42
|
+
if (typeof arg === 'number') return `${typeof second === 'string' ? second : 'localhost'}:${arg}`
|
|
43
|
+
return String(arg)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function refuse(via, ...args) {
|
|
47
|
+
const target = describeTarget(args.length > 1 ? args : args[0])
|
|
48
|
+
const message =
|
|
49
|
+
`[no-network] a test tried to open a socket to ${target} (${via}). ` +
|
|
50
|
+
'Tests must never reach the network: stub the client method, or point the client at a fake. ' +
|
|
51
|
+
'If a real CiphyrsClient is needed, give it a sentinel baseUrl and neutralise _announceTools.'
|
|
52
|
+
attempts.push(message)
|
|
53
|
+
// Throwing is not enough on its own. The tool-inventory announce is
|
|
54
|
+
// best-effort and swallows its own errors (`.catch(() => {})`), and a timer
|
|
55
|
+
// that fires between tests belongs to no test, so an exception there would
|
|
56
|
+
// be invisible. The process exit code is what the suite cannot swallow —
|
|
57
|
+
// see the exit handler below.
|
|
58
|
+
throw new Error(message)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
net.Socket.prototype.connect = function blockedConnect(...args) {
|
|
62
|
+
refuse('net.Socket.connect', ...args)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// TLSSocket inherits the patched connect above, so this is belt and braces for
|
|
66
|
+
// the paths that hand tls.connect an already-open socket.
|
|
67
|
+
tls.connect = function blockedTlsConnect(...args) {
|
|
68
|
+
refuse('tls.connect', ...args)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
process.on('exit', () => {
|
|
72
|
+
if (!attempts.length) return
|
|
73
|
+
console.error(`\n[no-network] ${attempts.length} network attempt(s) were blocked during this test file:`)
|
|
74
|
+
for (const a of attempts) console.error(` - ${a}`)
|
|
75
|
+
// A test that opened a socket may still have "passed" — the announce that
|
|
76
|
+
// started this swallowed its own failure. Failing the process is what makes
|
|
77
|
+
// the run red: node --test reports a non-zero subprocess as a failed file.
|
|
78
|
+
process.exitCode = 1
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The attempts blocked so far. For assertions; do not mutate.
|
|
83
|
+
*/
|
|
84
|
+
export function networkAttempts() {
|
|
85
|
+
return [...attempts]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run `fn`, assert it was stopped by this guard, and un-record the attempt so
|
|
90
|
+
* the deliberate one does not fail the run. This is how the guard proves it
|
|
91
|
+
* still works — a guard nothing exercises is a guard that quietly stops
|
|
92
|
+
* guarding.
|
|
93
|
+
*
|
|
94
|
+
* @returns {string} the message the guard produced
|
|
95
|
+
*/
|
|
96
|
+
export function expectBlocked(fn) {
|
|
97
|
+
const before = attempts.length
|
|
98
|
+
let err
|
|
99
|
+
try { fn() } catch (e) { err = e }
|
|
100
|
+
const recorded = attempts.splice(before)
|
|
101
|
+
if (!recorded.length) {
|
|
102
|
+
throw new Error('[no-network] expected the guard to block this call, but it went through')
|
|
103
|
+
}
|
|
104
|
+
if (!err || !/no-network/.test(err.message)) {
|
|
105
|
+
throw new Error(`[no-network] the guard recorded the attempt but did not throw for it (got: ${err?.message})`)
|
|
106
|
+
}
|
|
107
|
+
return recorded[0]
|
|
108
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Trace-context propagation between agents (W3C `traceparent` + `baggage`)
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. The topology graph is derived on the server: an edge
|
|
5
|
+
// A → B is drawn when a span of agent B names a span of agent A as its
|
|
6
|
+
// parent. Inside one process the SDK's AsyncLocalStorage provides that.
|
|
7
|
+
// Across processes nothing did — the SDK never wrote `traceparent` on an
|
|
8
|
+
// outbound call and never read it on an inbound one — so two agents that
|
|
9
|
+
// talked to each other constantly rendered as two disconnected nodes.
|
|
10
|
+
//
|
|
11
|
+
// Outbound: `instrumentFetch()` wraps global fetch and `instrumentHttp()`
|
|
12
|
+
// wraps node:http / node:https request+get, which together cover fetch,
|
|
13
|
+
// axios, node-fetch, got and superagent. Calls to the Ciphyrs API itself are
|
|
14
|
+
// never decorated (registerInternalOrigin, called by CiphyrsClient).
|
|
15
|
+
//
|
|
16
|
+
// Inbound: `expressMiddleware()`, `fastifyPlugin()` and the generic
|
|
17
|
+
// `withRemoteContext(headers, fn)` activate the caller's trace for the
|
|
18
|
+
// duration of a request. `tracer.trace()` then continues that trace and its
|
|
19
|
+
// first span is parented to the caller's span, so the edge exists as soon as
|
|
20
|
+
// both spans arrive, in either order (server-side V143).
|
|
21
|
+
//
|
|
22
|
+
// Anything else (queues, gRPC, a framework not listed): `inject(headers)`
|
|
23
|
+
// when you send, `withRemoteContext(headers, fn)` when you receive.
|
|
24
|
+
//
|
|
25
|
+
// ID SHAPE. `traceparent` requires 32-hex trace ids and 16-hex span ids.
|
|
26
|
+
// Since 2.7 the tracer generates ids in exactly that shape, so the header
|
|
27
|
+
// carries the REAL ids and an OpenTelemetry-instrumented peer joins the same
|
|
28
|
+
// trace. Ids that are not hex (one you supplied, or one from an older SDK)
|
|
29
|
+
// are hashed into the W3C fields while the exact originals travel in
|
|
30
|
+
// `baggage`, so a Ciphyrs peer still links precisely.
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { activeAgent, remoteParent, remoteStorage, spanStorage, traceStorage } from './context.js';
|
|
35
|
+
|
|
36
|
+
export const TRACEPARENT = 'traceparent';
|
|
37
|
+
export const BAGGAGE = 'baggage';
|
|
38
|
+
|
|
39
|
+
const B_TRACE = 'ciphyrs.trace_id';
|
|
40
|
+
const B_SPAN = 'ciphyrs.span_id';
|
|
41
|
+
const B_AGENT = 'ciphyrs.agent';
|
|
42
|
+
const B_PROJECT = 'ciphyrs.project';
|
|
43
|
+
|
|
44
|
+
const HEX32 = /^[0-9a-f]{32}$/;
|
|
45
|
+
const HEX16 = /^[0-9a-f]{16}$/;
|
|
46
|
+
const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
47
|
+
|
|
48
|
+
const ZERO32 = '0'.repeat(32);
|
|
49
|
+
const ZERO16 = '0'.repeat(16);
|
|
50
|
+
|
|
51
|
+
let defaultProject;
|
|
52
|
+
/** Set by CiphyrsTracer so baggage names the caller's project. */
|
|
53
|
+
export function setDefaultProject(name) { defaultProject = name || undefined; }
|
|
54
|
+
|
|
55
|
+
// Origins that belong to Ciphyrs itself. Calls there are the SDK reporting
|
|
56
|
+
// telemetry, not the agent doing work, and must not carry trace headers.
|
|
57
|
+
const internalOrigins = new Set(['https://www.ciphyrs.com', 'https://ciphyrs.com']);
|
|
58
|
+
export function registerInternalOrigin(url) {
|
|
59
|
+
try { internalOrigins.add(new URL(url).origin); } catch { /* not a URL — ignore */ }
|
|
60
|
+
}
|
|
61
|
+
function isInternal(url) {
|
|
62
|
+
try { return internalOrigins.has(new URL(url).origin); } catch { return false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── W3C field shaping ──────────────────────────────────────────────────────
|
|
66
|
+
export function w3cTraceId(traceId) {
|
|
67
|
+
const t = String(traceId).toLowerCase();
|
|
68
|
+
if (HEX32.test(t)) return t;
|
|
69
|
+
return createHash('sha256').update(String(traceId)).digest('hex').slice(0, 32);
|
|
70
|
+
}
|
|
71
|
+
export function w3cSpanId(spanId) {
|
|
72
|
+
const s = String(spanId).toLowerCase();
|
|
73
|
+
if (HEX16.test(s)) return s;
|
|
74
|
+
return createHash('sha256').update(String(spanId)).digest('hex').slice(0, 16);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Outbound ───────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The context an outbound call should carry: the local span if we are in one,
|
|
81
|
+
* else the trace, else the remote parent we are serving (pass-through).
|
|
82
|
+
*/
|
|
83
|
+
export function currentContext() {
|
|
84
|
+
const span = spanStorage.getStore();
|
|
85
|
+
if (span?.trace_id) {
|
|
86
|
+
return { trace_id: span.trace_id, span_id: span.span_id, agent_name: span.agent_name, project: defaultProject };
|
|
87
|
+
}
|
|
88
|
+
const trace = traceStorage.getStore();
|
|
89
|
+
if (trace?.trace_id) {
|
|
90
|
+
return { trace_id: trace.trace_id, span_id: undefined, agent_name: activeAgent(), project: defaultProject };
|
|
91
|
+
}
|
|
92
|
+
const remote = remoteParent();
|
|
93
|
+
if (remote?.trace_id) {
|
|
94
|
+
return {
|
|
95
|
+
trace_id: remote.trace_id, span_id: remote.span_id,
|
|
96
|
+
agent_name: activeAgent() || remote.agent_name,
|
|
97
|
+
project: defaultProject || remote.project,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mergeBaggage(existing, items) {
|
|
104
|
+
const kept = [];
|
|
105
|
+
for (const member of String(existing || '').split(',')) {
|
|
106
|
+
const m = member.trim();
|
|
107
|
+
if (!m) continue;
|
|
108
|
+
const key = m.split('=', 1)[0].trim();
|
|
109
|
+
if (key in items) continue; // ours — replaced, never duplicated
|
|
110
|
+
kept.push(m);
|
|
111
|
+
}
|
|
112
|
+
for (const [k, v] of Object.entries(items)) kept.push(`${k}=${encodeURIComponent(v)}`);
|
|
113
|
+
return kept.join(',');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function headerValues(ctx, existingBaggage) {
|
|
117
|
+
const spanHex = ctx.span_id ? w3cSpanId(ctx.span_id) : `${'0'.repeat(15)}1`;
|
|
118
|
+
const items = { [B_TRACE]: ctx.trace_id };
|
|
119
|
+
if (ctx.span_id) items[B_SPAN] = ctx.span_id;
|
|
120
|
+
if (ctx.agent_name) items[B_AGENT] = ctx.agent_name;
|
|
121
|
+
if (ctx.project) items[B_PROJECT] = ctx.project;
|
|
122
|
+
return {
|
|
123
|
+
[TRACEPARENT]: `00-${w3cTraceId(ctx.trace_id)}-${spanHex}-01`,
|
|
124
|
+
[BAGGAGE]: mergeBaggage(existingBaggage, items),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Add `traceparent` and `baggage` for the active context.
|
|
130
|
+
*
|
|
131
|
+
* Accepts (and returns) a plain object or a Headers/fetch-style object with
|
|
132
|
+
* get/set. Outside a trace it is a no-op, so it is safe to call always:
|
|
133
|
+
*
|
|
134
|
+
* await fetch(url, { headers: inject({ 'content-type': 'application/json' }) })
|
|
135
|
+
*/
|
|
136
|
+
export function inject(headers = {}) {
|
|
137
|
+
const ctx = currentContext();
|
|
138
|
+
if (!ctx) return headers;
|
|
139
|
+
const settable = typeof headers?.set === 'function';
|
|
140
|
+
const existing = settable
|
|
141
|
+
? headers.get?.(BAGGAGE)
|
|
142
|
+
: headers[BAGGAGE] ?? headers[BAGGAGE.toUpperCase()] ?? headers.Baggage;
|
|
143
|
+
const values = headerValues(ctx, existing);
|
|
144
|
+
for (const [k, v] of Object.entries(values)) {
|
|
145
|
+
if (settable) headers.set(k, v); else headers[k] = v;
|
|
146
|
+
}
|
|
147
|
+
return headers;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Inbound ────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function lookup(headers) {
|
|
153
|
+
const out = {};
|
|
154
|
+
if (!headers) return out;
|
|
155
|
+
const want = new Set([TRACEPARENT, BAGGAGE]);
|
|
156
|
+
const take = (k, v) => {
|
|
157
|
+
if (v == null) return;
|
|
158
|
+
let key = String(k).toLowerCase();
|
|
159
|
+
if (key.startsWith('http_')) key = key.slice(5).replace(/_/g, '-'); // CGI-style
|
|
160
|
+
if (want.has(key)) out[key] = Array.isArray(v) ? v[0] : String(v);
|
|
161
|
+
};
|
|
162
|
+
if (typeof headers.get === 'function') { // Headers / fetch Request
|
|
163
|
+
for (const key of want) take(key, headers.get(key));
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
if (typeof headers.forEach === 'function' && !Array.isArray(headers)) { // Map-like
|
|
167
|
+
headers.forEach((v, k) => take(k, v));
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(headers)) { // [[k, v], …]
|
|
171
|
+
for (const pair of headers) if (Array.isArray(pair)) take(pair[0], pair[1]);
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
for (const [k, v] of Object.entries(headers)) take(k, v); // node req.headers
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseBaggage(raw) {
|
|
179
|
+
const out = {};
|
|
180
|
+
for (const member of String(raw || '').split(',')) {
|
|
181
|
+
const m = member.trim();
|
|
182
|
+
if (!m || !m.includes('=')) continue;
|
|
183
|
+
const kv = m.split(';', 1)[0];
|
|
184
|
+
const idx = kv.indexOf('=');
|
|
185
|
+
const k = kv.slice(0, idx).trim();
|
|
186
|
+
if (k) out[k] = decodeURIComponent(kv.slice(idx + 1).trim());
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read the caller's trace context from inbound headers. Returns undefined
|
|
193
|
+
* when nothing usable is present. Ciphyrs baggage (exact ids) wins over
|
|
194
|
+
* `traceparent` (hashed/hex ids).
|
|
195
|
+
*/
|
|
196
|
+
export function extract(headers) {
|
|
197
|
+
const h = lookup(headers);
|
|
198
|
+
if (!Object.keys(h).length) return undefined;
|
|
199
|
+
const bag = parseBaggage(h[BAGGAGE]);
|
|
200
|
+
let traceId = bag[B_TRACE] || undefined;
|
|
201
|
+
let spanId = bag[B_SPAN] || undefined;
|
|
202
|
+
let sampled = true;
|
|
203
|
+
const tp = h[TRACEPARENT];
|
|
204
|
+
if (tp) {
|
|
205
|
+
const m = TRACEPARENT_RE.exec(String(tp).trim().toLowerCase());
|
|
206
|
+
if (m) {
|
|
207
|
+
const [, , tpTrace, tpSpan, flags] = m;
|
|
208
|
+
if (tpTrace !== ZERO32) {
|
|
209
|
+
traceId = traceId || tpTrace;
|
|
210
|
+
spanId = spanId || (tpSpan !== ZERO16 ? tpSpan : undefined);
|
|
211
|
+
}
|
|
212
|
+
const f = parseInt(flags, 16);
|
|
213
|
+
sampled = Number.isNaN(f) ? true : Boolean(f & 0x01);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!traceId) return undefined;
|
|
217
|
+
return {
|
|
218
|
+
trace_id: traceId,
|
|
219
|
+
span_id: spanId,
|
|
220
|
+
agent_name: bag[B_AGENT] || undefined,
|
|
221
|
+
project: bag[B_PROJECT] || undefined,
|
|
222
|
+
sampled,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Run `fn` as the continuation of a caller's trace:
|
|
228
|
+
*
|
|
229
|
+
* await withRemoteContext(message.headers, async () => {
|
|
230
|
+
* const t = tracer.trace('handle job'); // continues the trace
|
|
231
|
+
* await t.span('worker-agent').run(async () => {}) // parented to the caller
|
|
232
|
+
* })
|
|
233
|
+
*
|
|
234
|
+
* Accepts headers in any shape `extract` takes, or a ready context object.
|
|
235
|
+
* With nothing usable it simply runs `fn`.
|
|
236
|
+
*/
|
|
237
|
+
export function withRemoteContext(headersOrCtx, fn) {
|
|
238
|
+
const ctx = headersOrCtx && headersOrCtx.trace_id ? headersOrCtx : extract(headersOrCtx);
|
|
239
|
+
if (!ctx) return fn();
|
|
240
|
+
return remoteStorage.run(ctx, fn);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Client instrumentation ─────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
let fetchPatched = false;
|
|
246
|
+
/** Wrap global fetch so calls made inside a span carry the headers. Idempotent. */
|
|
247
|
+
export function instrumentFetch() {
|
|
248
|
+
if (fetchPatched || typeof globalThis.fetch !== 'function') return false;
|
|
249
|
+
const original = globalThis.fetch;
|
|
250
|
+
globalThis.fetch = function ciphyrsFetch(input, init = {}) {
|
|
251
|
+
try {
|
|
252
|
+
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
|
|
253
|
+
if (!init?.ciphyrsInternal && !isInternal(url) && currentContext()) {
|
|
254
|
+
// A Request object's headers are immutable through init, so rebuild.
|
|
255
|
+
if (typeof Request !== 'undefined' && input instanceof Request) {
|
|
256
|
+
const headers = new Headers(input.headers);
|
|
257
|
+
inject(headers);
|
|
258
|
+
return original.call(this, new Request(input, { headers }), init);
|
|
259
|
+
}
|
|
260
|
+
const headers = init.headers instanceof Headers ? init.headers : new Headers(init.headers || {});
|
|
261
|
+
inject(headers);
|
|
262
|
+
return original.call(this, input, { ...init, headers });
|
|
263
|
+
}
|
|
264
|
+
} catch { /* never break the customer's call */ }
|
|
265
|
+
return original.call(this, input, init);
|
|
266
|
+
};
|
|
267
|
+
globalThis.fetch.__ciphyrsOriginal = original;
|
|
268
|
+
fetchPatched = true;
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let httpPatched = false;
|
|
273
|
+
/**
|
|
274
|
+
* Wrap node:http / node:https request+get, which is how axios, node-fetch,
|
|
275
|
+
* got and superagent actually send. Idempotent; returns false if already done.
|
|
276
|
+
*/
|
|
277
|
+
export function instrumentHttp() {
|
|
278
|
+
if (httpPatched) return false;
|
|
279
|
+
let http, https;
|
|
280
|
+
try {
|
|
281
|
+
// A static `import` of node:http would be evaluated in every environment
|
|
282
|
+
// that loads this module, including browsers/bundlers where it does not
|
|
283
|
+
// exist. createRequire keeps the dependency lazy and Node-only.
|
|
284
|
+
const req = createRequire(import.meta.url);
|
|
285
|
+
http = req('node:http');
|
|
286
|
+
https = req('node:https');
|
|
287
|
+
} catch {
|
|
288
|
+
return false; // not Node, or no CJS resolver
|
|
289
|
+
}
|
|
290
|
+
const patch = (mod, name, scheme) => {
|
|
291
|
+
const original = mod[name];
|
|
292
|
+
if (typeof original !== 'function' || original.__ciphyrs) return;
|
|
293
|
+
const wrapped = function ciphyrsRequest(...args) {
|
|
294
|
+
try {
|
|
295
|
+
const ctx = currentContext();
|
|
296
|
+
if (ctx) {
|
|
297
|
+
// Signatures: (url[, options][, cb]) and (options[, cb]).
|
|
298
|
+
let urlStr = null;
|
|
299
|
+
let optIdx = -1;
|
|
300
|
+
if (typeof args[0] === 'string' || args[0] instanceof URL) {
|
|
301
|
+
urlStr = String(args[0]);
|
|
302
|
+
if (args[1] && typeof args[1] === 'object') optIdx = 1;
|
|
303
|
+
} else if (args[0] && typeof args[0] === 'object') {
|
|
304
|
+
optIdx = 0;
|
|
305
|
+
const o = args[0];
|
|
306
|
+
const host = o.host || o.hostname || 'localhost';
|
|
307
|
+
urlStr = `${o.protocol || scheme}//${host}${o.path || '/'}`;
|
|
308
|
+
}
|
|
309
|
+
if (!isInternal(urlStr)) {
|
|
310
|
+
if (optIdx === -1) {
|
|
311
|
+
// No options object to carry headers — add one.
|
|
312
|
+
const opts = {};
|
|
313
|
+
inject(opts.headers = {});
|
|
314
|
+
args.splice(typeof args[1] === 'function' ? 1 : args.length, 0, opts);
|
|
315
|
+
} else {
|
|
316
|
+
const opts = args[optIdx];
|
|
317
|
+
opts.headers = opts.headers || {};
|
|
318
|
+
inject(opts.headers);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch { /* never break the customer's call */ }
|
|
323
|
+
return original.apply(this, args);
|
|
324
|
+
};
|
|
325
|
+
wrapped.__ciphyrs = true;
|
|
326
|
+
wrapped.__ciphyrsOriginal = original;
|
|
327
|
+
mod[name] = wrapped;
|
|
328
|
+
};
|
|
329
|
+
patch(http, 'request', 'http:');
|
|
330
|
+
patch(http, 'get', 'http:');
|
|
331
|
+
patch(https, 'request', 'https:');
|
|
332
|
+
patch(https, 'get', 'https:');
|
|
333
|
+
httpPatched = true;
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Instrument every supported outbound client. Called by CiphyrsTracer. */
|
|
338
|
+
export function autoInstrument() {
|
|
339
|
+
return { fetch: instrumentFetch(), http: instrumentHttp() };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Server integrations ────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Express / Connect middleware:
|
|
346
|
+
*
|
|
347
|
+
* app.use(expressMiddleware())
|
|
348
|
+
*/
|
|
349
|
+
export function expressMiddleware() {
|
|
350
|
+
return function ciphyrsPropagation(req, res, next) {
|
|
351
|
+
const ctx = extract(req.headers);
|
|
352
|
+
if (!ctx) return next();
|
|
353
|
+
return remoteStorage.run(ctx, () => next());
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Fastify plugin:
|
|
359
|
+
*
|
|
360
|
+
* await app.register(fastifyPlugin)
|
|
361
|
+
*
|
|
362
|
+
* Uses an onRequest hook wrapped around the rest of the lifecycle via
|
|
363
|
+
* AsyncLocalStorage.run, which Fastify propagates to handlers.
|
|
364
|
+
*/
|
|
365
|
+
export async function fastifyPlugin(app) {
|
|
366
|
+
app.addHook('onRequest', (req, reply, done) => {
|
|
367
|
+
const ctx = extract(req.headers);
|
|
368
|
+
if (!ctx) return done();
|
|
369
|
+
return remoteStorage.run(ctx, () => done());
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
fastifyPlugin[Symbol.for('skip-override')] = true;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Raw node http handler wrapper:
|
|
376
|
+
*
|
|
377
|
+
* http.createServer(withPropagation((req, res) => { … }))
|
|
378
|
+
*/
|
|
379
|
+
export function withPropagation(handler) {
|
|
380
|
+
return function ciphyrsHandler(req, res) {
|
|
381
|
+
const ctx = extract(req.headers);
|
|
382
|
+
if (!ctx) return handler(req, res);
|
|
383
|
+
return remoteStorage.run(ctx, () => handler(req, res));
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** @internal — exported for tests. */
|
|
388
|
+
export const _parseBaggage = parseBaggage;
|