@ciphyrshq/sdk 2.6.0 → 3.0.1
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 +24 -6
- package/src/client.js +326 -20
- package/src/context.js +82 -0
- package/src/fail-posture.js +100 -0
- package/src/index.js +14 -0
- package/src/propagation.js +388 -0
- package/src/protect-tool.js +362 -0
- package/src/secret-detector.js +21 -3
- package/src/tracer.js +278 -9
- package/types.d.ts +265 -5
package/src/context.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Ambient trace/span context for the JavaScript SDK.
|
|
3
|
+
//
|
|
4
|
+
// WHY. Spans took `parentSpanId` as a hand-threaded option, so in practice
|
|
5
|
+
// nothing was ever nested: every span in a trace arrived parentless. The
|
|
6
|
+
// server derives the topology graph from parent links, so a flat trace draws
|
|
7
|
+
// no edges — the customer saw a row of disconnected agents and concluded
|
|
8
|
+
// topology was broken. It also meant a guard call inside a span had no span
|
|
9
|
+
// to point at, exactly the defect the Python SDK had (measured on
|
|
10
|
+
// production: every guard-path detection had a NULL span_id).
|
|
11
|
+
//
|
|
12
|
+
// AsyncLocalStorage, not a module global: a global is wrong the moment two
|
|
13
|
+
// requests are in flight, which is the normal shape of a Node agent. ALS is
|
|
14
|
+
// per-async-context and is inherited by anything the current task spawns.
|
|
15
|
+
//
|
|
16
|
+
// TWO WAYS IN, deliberately. `span.run(fn)` is a real scope and is what the
|
|
17
|
+
// docs recommend. But this SDK's public shape has always been
|
|
18
|
+
// `const s = trace.span(...); …; s.end()`, with no callback, so
|
|
19
|
+
// `enterWith` is used at construction and unwound at `end()`. That is what
|
|
20
|
+
// makes the existing style nest correctly without asking every user to
|
|
21
|
+
// rewrite their agent.
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
24
|
+
|
|
25
|
+
export const traceStorage = new AsyncLocalStorage();
|
|
26
|
+
export const spanStorage = new AsyncLocalStorage();
|
|
27
|
+
|
|
28
|
+
/** The trace/span an inbound request arrived under (see propagation.js). */
|
|
29
|
+
export const remoteStorage = new AsyncLocalStorage();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The active trace and span, if any.
|
|
33
|
+
*
|
|
34
|
+
* Prefers the span, which carries both ids — a guard call made inside span()
|
|
35
|
+
* should attribute to that span, not merely to its trace. Falls back to the
|
|
36
|
+
* remote parent so a guard call made straight from a request handler, with no
|
|
37
|
+
* local span open, still attributes to the distributed trace it belongs to.
|
|
38
|
+
* Returns an object with undefined fields rather than null so callers can
|
|
39
|
+
* spread it without branching.
|
|
40
|
+
*/
|
|
41
|
+
export function activeIds() {
|
|
42
|
+
const span = spanStorage.getStore();
|
|
43
|
+
if (span?.trace_id) {
|
|
44
|
+
return { trace_id: span.trace_id, span_id: span.span_id };
|
|
45
|
+
}
|
|
46
|
+
const trace = traceStorage.getStore();
|
|
47
|
+
if (trace?.trace_id) {
|
|
48
|
+
return { trace_id: trace.trace_id, span_id: undefined };
|
|
49
|
+
}
|
|
50
|
+
const remote = remoteStorage.getStore();
|
|
51
|
+
if (remote?.trace_id) {
|
|
52
|
+
return { trace_id: remote.trace_id, span_id: remote.span_id };
|
|
53
|
+
}
|
|
54
|
+
return { trace_id: undefined, span_id: undefined };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The kind of the innermost LOCAL span ('agent' | 'tool' | 'llm' | 'retriever'),
|
|
59
|
+
* or undefined when no local span is open.
|
|
60
|
+
*
|
|
61
|
+
* Only spanStorage answers this, deliberately. A span kind never travels in
|
|
62
|
+
* Ciphyrs baggage, so a remote parent has none, and a trace is not a span —
|
|
63
|
+
* in both of those cases the honest answer is "I do not know what that span
|
|
64
|
+
* is", not "agent". The one caller that needs it is protect-tool.js: telling
|
|
65
|
+
* the gateway which span authorised a tool call is only true if the span is
|
|
66
|
+
* the tool span, and claiming it otherwise is worse than saying nothing.
|
|
67
|
+
*/
|
|
68
|
+
export function activeSpanKind() {
|
|
69
|
+
return spanStorage.getStore()?.kind || undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The agent name of the innermost active span, for outbound headers. */
|
|
73
|
+
export function activeAgent() {
|
|
74
|
+
return spanStorage.getStore()?.agent_name
|
|
75
|
+
|| remoteStorage.getStore()?.agent_name
|
|
76
|
+
|| undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The remote parent, if this async context is serving an instrumented request. */
|
|
80
|
+
export function remoteParent() {
|
|
81
|
+
return remoteStorage.getStore() || undefined;
|
|
82
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// One failure posture for the whole client.
|
|
3
|
+
//
|
|
4
|
+
// WHY. The three entry points that stand between a customer and their
|
|
5
|
+
// LLM/tool disagreed about what a Ciphyrs outage means. protectTool caught
|
|
6
|
+
// the transport error and ran the tool anyway (fail-OPEN); guard.wrap and
|
|
7
|
+
// scan.protect let the error propagate out of check()/mask(), so nothing ran
|
|
8
|
+
// (fail-CLOSED). One client, two opposite behaviours under one outage — and
|
|
9
|
+
// the unsafe one sat on the path that authorises tool execution, i.e. the
|
|
10
|
+
// path that moves money. Neither closed path offered any way to opt out, so
|
|
11
|
+
// a customer could not align them either.
|
|
12
|
+
//
|
|
13
|
+
// So the posture is one option, resolved here, and used identically by all
|
|
14
|
+
// three: `failOpen: true` means "a Ciphyrs outage must not stop my work",
|
|
15
|
+
// `failOpen: false` (the default, everywhere) means the work does not
|
|
16
|
+
// proceed. Settable on the client and overridable per call.
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
/** What to tell a caller who just got blocked by an outage. */
|
|
20
|
+
export const FAIL_OPEN_HINT =
|
|
21
|
+
'To run anyway while Ciphyrs is unreachable, pass { failOpen: true } on this call ' +
|
|
22
|
+
'or construct the client with new CiphyrsClient({ failOpen: true }).'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read one posture value: true, false, or undefined for "nobody chose".
|
|
26
|
+
*
|
|
27
|
+
* WHY this is not `Boolean(v)` / `!v`. A posture reaches us from
|
|
28
|
+
* configuration at least as often as from code — a JSON file, an env var, a
|
|
29
|
+
* database column, a `{ ...defaults }` spread — and those hand over `null` or
|
|
30
|
+
* a string, which plain truthiness reads the UNSAFE way:
|
|
31
|
+
*
|
|
32
|
+
* `!null` is true, so a `failClosed` column nobody has ever set resolved to
|
|
33
|
+
* fail OPEN — an unset value silently choosing the direction that runs the
|
|
34
|
+
* tool during an outage.
|
|
35
|
+
* `Boolean('false')` is true, so `failOpen: process.env.FAIL_OPEN` with the
|
|
36
|
+
* var set to "false" resolved to fail OPEN too — a written-down "no" read as
|
|
37
|
+
* a yes.
|
|
38
|
+
*
|
|
39
|
+
* Fail-open is the direction that costs the customer money or leaks their PII,
|
|
40
|
+
* so it is only ever reached by a value that unmistakably says so. Anything
|
|
41
|
+
* else — null, '', an object, NaN, a string we cannot read — is "no choice
|
|
42
|
+
* made", which falls through to the next level and ultimately to fail CLOSED.
|
|
43
|
+
* Note the asymmetry that made this urgent: a nullish `failOpen` already
|
|
44
|
+
* landed safely; only the inverted key landed unsafely.
|
|
45
|
+
*/
|
|
46
|
+
const YES = new Set(['true', '1', 'yes', 'on'])
|
|
47
|
+
const NO = new Set(['false', '0', 'no', 'off'])
|
|
48
|
+
|
|
49
|
+
function readPosture(v) {
|
|
50
|
+
if (v === undefined || v === null) return undefined
|
|
51
|
+
if (typeof v === 'boolean') return v
|
|
52
|
+
// 0/1 is how a boolean survives MySQL and SQLite, so a number is a real
|
|
53
|
+
// choice — except NaN, which is what `Number(undefined)` produces.
|
|
54
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v !== 0 : undefined
|
|
55
|
+
if (typeof v === 'string') {
|
|
56
|
+
const s = v.trim().toLowerCase()
|
|
57
|
+
if (YES.has(s)) return true
|
|
58
|
+
if (NO.has(s)) return false
|
|
59
|
+
return undefined // '' or anything unrecognised: not a choice
|
|
60
|
+
}
|
|
61
|
+
return undefined // objects, arrays, functions: not a posture
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Should this call proceed when Ciphyrs cannot be reached?
|
|
66
|
+
*
|
|
67
|
+
* @param {object} [callOpts] Per-call options ({ failOpen }, plus legacy
|
|
68
|
+
* { failClosed } on the surfaces that opt in)
|
|
69
|
+
* @param {boolean} [clientDefault=false] The client-wide posture
|
|
70
|
+
* @param {object} [surface]
|
|
71
|
+
* @param {boolean} [surface.legacyFailClosed=false]
|
|
72
|
+
* Read the legacy `failClosed` key on this surface. OFF by default, and
|
|
73
|
+
* opted into by protectTool alone, because protectTool is the only
|
|
74
|
+
* surface `failClosed` ever did anything on. guard.wrap ignored the key
|
|
75
|
+
* outright and scan.protect forwarded it to mask(), which whitelists its
|
|
76
|
+
* fields, so it was inert there too. Reading it everywhere turned a key
|
|
77
|
+
* someone copy-pasted from a protectTool call site into a live switch on
|
|
78
|
+
* two new surfaces — and `{ failClosed: false }` on scan.protect means
|
|
79
|
+
* sending UNMASKED text to the customer's model. A resolver shared by
|
|
80
|
+
* three surfaces must not hand a fourth one the legacy key by default.
|
|
81
|
+
* @returns {boolean} true = fail open (run anyway), false = fail closed (do not run)
|
|
82
|
+
*/
|
|
83
|
+
export function resolveFailOpen(callOpts, clientDefault = false, { legacyFailClosed = false } = {}) {
|
|
84
|
+
const o = (callOpts && typeof callOpts === 'object') ? callOpts : {}
|
|
85
|
+
|
|
86
|
+
const open = readPosture(o.failOpen)
|
|
87
|
+
if (open !== undefined) return open
|
|
88
|
+
|
|
89
|
+
// `failClosed` has shipped on protectTool since V60 and must keep working in
|
|
90
|
+
// BOTH directions there. A caller who wrote `failClosed: false` wrote down a
|
|
91
|
+
// request to fail open; silently re-reading that as "did not choose" and
|
|
92
|
+
// applying the new fail-closed default is precisely the upgrade surprise
|
|
93
|
+
// this option exists to prevent.
|
|
94
|
+
if (legacyFailClosed) {
|
|
95
|
+
const closed = readPosture(o.failClosed)
|
|
96
|
+
if (closed !== undefined) return !closed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return readPosture(clientDefault) === true
|
|
100
|
+
}
|
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,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;
|