@ancha-labs/record 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 (3) hide show
  1. package/README.md +16 -2
  2. package/package.json +21 -4
  3. package/src/index.mjs +96 -0
package/README.md CHANGED
@@ -1,3 +1,17 @@
1
- # Temporary Holding Version
1
+ # @ancha-labs/record
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Puts the request and response bodies of your agent's API calls into your OpenTelemetry traces. Ancha rebuilds the
4
+ world your agent saw from those bodies; OpenTelemetry's HTTP instrumentation leaves them out.
5
+
6
+ ```sh
7
+ npm install @ancha-labs/record @opentelemetry/api
8
+ node --import ./otel.js --import @ancha-labs/record your-agent.js # otel.js starts your OpenTelemetry SDK
9
+ ```
10
+
11
+ - **What becomes a span:** each `fetch` or `node:http(s)` call to a tool Ancha replicates (Slack, GitHub, Datadog, PagerDuty, Stripe, and the hosts in `VENDOR_API_BASES`). It is a client span on your own tracer, so it goes out through your own exporter, with `url.full`, the method, the status, `ancha.service`, and the bodies (`ancha.http.request.body`, `ancha.http.response.body`, 64 KB each at most). Other calls are left alone.
12
+ - **Headers are never recorded,** so tokens never leave the process. Ancha removes credentials found in bodies before it stores anything.
13
+ - **Webhooks:** call `recordInbound('slack', rawBody)` in the handler, while the request's server span is active.
14
+ - **A proxy or private host** in front of a tool: `ANCHA_RECORD_HOSTS=api.internal.example=stripe`.
15
+ - **Streams:** server-sent event responses are passed through and not buffered.
16
+
17
+ Docs: [OTLP](https://docs.ancha.io/reference/otlp). MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "@ancha-labs/record",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Adds the request and response bodies of your agent's API calls to your OpenTelemetry traces, so Ancha can rebuild the world your agent saw.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.mjs"
11
+ },
12
+ "peerDependencies": {
13
+ "@opentelemetry/api": "^1.9.0"
14
+ },
15
+ "homepage": "https://docs.ancha.io",
16
+ "license": "MIT",
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,96 @@
1
+ // @ancha-labs/record: puts the request and response bodies of your agent's API calls into your OpenTelemetry traces
2
+ // (TRACE_PLAN §1.5.3 step 1). OpenTelemetry's HTTP instrumentation leaves bodies out, and Ancha rebuilds the world your
3
+ // agent saw from them. Each call to a tool Ancha replicates becomes a client span on your own tracer, so it travels
4
+ // through your own exporter. Headers are never recorded, so credentials never leave the process.
5
+ //
6
+ // node --import @ancha-labs/record your-agent.js (after your OpenTelemetry SDK starts)
7
+ // import '@ancha-labs/record'; (the same, from code)
8
+ import { createRequire, syncBuiltinESMExports } from 'node:module';
9
+ import { SpanKind, SpanStatusCode, context, trace } from '@opentelemetry/api';
10
+
11
+ /** Vendor API bases Ancha reads (cloud/rebuild/ir.mjs keeps the same table; a test holds them equal). */
12
+ export const VENDOR_API_BASES = Object.freeze({
13
+ slack: 'https://slack.com', github: 'https://api.github.com', datadog: 'https://api.datadoghq.com', pagerduty: 'https://api.pagerduty.com',
14
+ stripe: 'https://api.stripe.com', linear: 'https://api.linear.app', notion: 'https://api.notion.com', intercom: 'https://api.intercom.io',
15
+ sentry: 'https://sentry.io', twilio: 'https://api.twilio.com', resend: 'https://api.resend.com', airtable: 'https://api.airtable.com',
16
+ attio: 'https://api.attio.com', hubspot: 'https://api.hubapi.com', gmail: 'https://gmail.googleapis.com',
17
+ });
18
+ const MAX_BODY = 64 * 1024;
19
+ const tracer = trace.getTracer('@ancha-labs/record', '0.0.0');
20
+ const require = createRequire(import.meta.url);
21
+ let hosts = new Map(), installed = false;
22
+
23
+ /** host → tool. `ANCHA_RECORD_HOSTS=api.internal.example=stripe,…` adds hosts, e.g. a proxy in front of a vendor. */
24
+ function hostTable(extra = process.env.ANCHA_RECORD_HOSTS ?? '') {
25
+ const table = new Map(Object.entries(VENDOR_API_BASES).map(([service, base]) => [new URL(base).host, service]));
26
+ for (const pair of extra.split(',').map(s => s.trim()).filter(Boolean)) { const [host, service] = pair.split('='); if (host && service) table.set(host, service); }
27
+ return table;
28
+ }
29
+ const serviceOf = (url) => { try { return hosts.get(new URL(url).host); } catch { return undefined; } };
30
+ const clip = (text) => (text.length > MAX_BODY ? `${text.slice(0, MAX_BODY)}[truncated]` : text);
31
+ const textOf = (body) => (typeof body === 'string' ? body : body instanceof URLSearchParams ? body.toString() : body instanceof Uint8Array ? Buffer.from(body).toString('utf8') : '');
32
+
33
+ /** One client span for a finished call. */
34
+ function record({ service, method, url, request, status, response, start }) {
35
+ const span = tracer.startSpan(`${method} ${service}`, { kind: SpanKind.CLIENT, startTime: start, attributes: {
36
+ 'http.request.method': method, 'url.full': url, 'server.address': new URL(url).host, 'ancha.service': service,
37
+ ...(status !== undefined && { 'http.response.status_code': status }),
38
+ ...(request && { 'ancha.http.request.body': clip(request) }), ...(response && { 'ancha.http.response.body': clip(response) }),
39
+ } }, context.active());
40
+ if (status === undefined || status >= 400) span.setStatus({ code: SpanStatusCode.ERROR });
41
+ span.end();
42
+ }
43
+
44
+ /**
45
+ * Marks the webhook your handler is serving: call it with the tool and the raw body, inside the request's span (OpenTelemetry's
46
+ * HTTP server instrumentation makes it the active one). `recordInbound('slack', rawBody)`.
47
+ */
48
+ export function recordInbound(service, body) {
49
+ const span = trace.getActiveSpan(); if (!span) return;
50
+ span.setAttribute('ancha.inbound.service', service);
51
+ span.setAttribute('ancha.http.request.body', clip(textOf(body)));
52
+ }
53
+
54
+ /** Patches fetch and node:http(s) once. Importing this module calls it; call it again to change the hosts. */
55
+ export function install({ extraHosts } = {}) {
56
+ hosts = hostTable(extraHosts);
57
+ if (installed) return;
58
+ installed = true;
59
+ const originalFetch = globalThis.fetch;
60
+ if (originalFetch) globalThis.fetch = async function (input, init) {
61
+ const url = input instanceof Request ? input.url : String(input), service = serviceOf(url);
62
+ if (!service) return originalFetch(input, init);
63
+ const start = new Date(), method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
64
+ const request = init?.body !== undefined ? textOf(init.body) : input instanceof Request && method !== 'GET' ? await input.clone().text().catch(() => '') : '';
65
+ let response;
66
+ try { response = await originalFetch(input, init); }
67
+ catch (error) { record({ service, method, url, request, start }); throw error; }
68
+ // Server-sent streams are not buffered; everything else is read from a clone, after the caller has its response.
69
+ const streaming = (response.headers.get('content-type') ?? '').includes('text/event-stream');
70
+ const done = streaming ? Promise.resolve('') : response.clone().text().catch(() => '');
71
+ done.then(text => record({ service, method, url, request, status: response.status, response: text, start }));
72
+ return response;
73
+ };
74
+ for (const name of ['http', 'https']) {
75
+ const mod = require(`node:${name}`), originalRequest = mod.request;
76
+ mod.request = function (...args) {
77
+ const req = originalRequest.apply(this, args), [target] = args;
78
+ const url = typeof target === 'string' || target instanceof URL ? String(target)
79
+ : `${target.protocol ?? `${name}:`}//${target.hostname ?? target.host}${target.port ? `:${target.port}` : ''}${target.path ?? '/'}`;
80
+ const service = serviceOf(url); if (!service) return req;
81
+ const start = new Date(), sent = [], write = req.write, end = req.end;
82
+ req.write = function (chunk, ...rest) { if (chunk && typeof chunk !== 'function') sent.push(Buffer.from(chunk)); return write.call(this, chunk, ...rest); };
83
+ req.end = function (chunk, ...rest) { if (chunk && typeof chunk !== 'function') sent.push(Buffer.from(chunk)); return end.call(this, chunk, ...rest); };
84
+ req.on('response', res => {
85
+ const received = [];
86
+ res.on('data', c => received.push(Buffer.from(c)));
87
+ res.on('end', () => record({ service, method: req.method, url, request: Buffer.concat(sent).toString('utf8'), status: res.statusCode, response: Buffer.concat(received).toString('utf8'), start }));
88
+ });
89
+ req.on('error', () => record({ service, method: req.method, url, request: Buffer.concat(sent).toString('utf8'), start }));
90
+ return req;
91
+ };
92
+ }
93
+ syncBuiltinESMExports();
94
+ }
95
+
96
+ install();