@tellann/backend-sdk 0.1.0 → 0.3.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.
@@ -1,7 +1,10 @@
1
1
  import { TrackApiOptions } from './trackApi';
2
2
  import { CaptureErrorOptions } from './captureError';
3
3
  import { TrackStateOptions } from './trackState';
4
+ import { TrackDataAccessOptions } from './trackDataAccess';
5
+ import type { TellannRequestContext } from './requestContext';
4
6
  import { BackendWorkflowTracker } from './workflowTracker';
7
+ import type { TellannCaptureConfig } from './capture';
5
8
  import type { EventType } from '../event-types';
6
9
  export interface TellannBackendConfig {
7
10
  endpoint: string;
@@ -14,6 +17,12 @@ export interface TellannBackendConfig {
14
17
  traceId?: string;
15
18
  agentVersion?: string;
16
19
  instrumentationManifestVersion?: string;
20
+ /**
21
+ * What a captured request may carry. Bodies and headers are on by default so
22
+ * a QA run can show what was actually sent; credential-shaped fields are
23
+ * dropped here regardless, before anything leaves this process.
24
+ */
25
+ capture?: TellannCaptureConfig;
17
26
  }
18
27
  export declare class TELLANNBackend {
19
28
  private config;
@@ -24,6 +33,16 @@ export declare class TELLANNBackend {
24
33
  trackApi(options: TrackApiOptions): Promise<void>;
25
34
  captureError(options: CaptureErrorOptions): Promise<void>;
26
35
  trackState(options: TrackStateOptions): Promise<void>;
36
+ /**
37
+ * Reports one persistence operation, and attaches it to the request that is
38
+ * in flight so that request can say which models it touched.
39
+ */
40
+ trackDataAccess(options: TrackDataAccessOptions): Promise<void>;
41
+ /**
42
+ * Sends what a finished request touched, one event per model and operation.
43
+ * The framework integrations call this; applications rarely need to.
44
+ */
45
+ flushDataAccess(context: TellannRequestContext | undefined): Promise<void>;
27
46
  trackEvent(eventType: EventType, metadata?: Record<string, any>, sessionId?: string): Promise<void>;
28
47
  verifyInstallation(sessionId?: string): Promise<void>;
29
48
  startWorkflow(workflowName: string, sessionId?: string): string;
@@ -4,6 +4,7 @@ exports.BackendWorkflowTracker = exports.TELLANN = exports.TELLANNBackend = void
4
4
  const trackApi_1 = require("./trackApi");
5
5
  const captureError_1 = require("./captureError");
6
6
  const trackState_1 = require("./trackState");
7
+ const trackDataAccess_1 = require("./trackDataAccess");
7
8
  const workflowTracker_1 = require("./workflowTracker");
8
9
  Object.defineProperty(exports, "BackendWorkflowTracker", { enumerable: true, get: function () { return workflowTracker_1.BackendWorkflowTracker; } });
9
10
  const uuid_1 = require("uuid");
@@ -35,6 +36,24 @@ class TELLANNBackend {
35
36
  return;
36
37
  await (0, trackState_1.trackStateEvent)(this.config, options);
37
38
  }
39
+ /**
40
+ * Reports one persistence operation, and attaches it to the request that is
41
+ * in flight so that request can say which models it touched.
42
+ */
43
+ async trackDataAccess(options) {
44
+ if (!this.config)
45
+ return;
46
+ await (0, trackDataAccess_1.trackDataAccessEvent)(this.config, options);
47
+ }
48
+ /**
49
+ * Sends what a finished request touched, one event per model and operation.
50
+ * The framework integrations call this; applications rarely need to.
51
+ */
52
+ async flushDataAccess(context) {
53
+ if (!this.config)
54
+ return;
55
+ await (0, trackDataAccess_1.flushRequestDataAccess)(this.config, context);
56
+ }
38
57
  async trackEvent(eventType, metadata = {}, sessionId) {
39
58
  await this.sendEvent(eventType, sessionId, metadata);
40
59
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What a captured request is allowed to carry out of the application process.
3
+ *
4
+ * Two independent protections apply to a QA run's payloads. This module is the
5
+ * first: anything that looks like a credential never leaves the server at all,
6
+ * and bodies are clipped so one large upload cannot push an event past the
7
+ * collector's size limit. The second lives in the desktop and the ingestion
8
+ * pipeline, which classify every remaining leaf and encrypt it at rest.
9
+ *
10
+ * Doing it here as well matters because the value would otherwise sit in a
11
+ * relay buffer, a spool file and a log line before the classifier ever sees it.
12
+ */
13
+ export interface TellannCaptureConfig {
14
+ /** Capture request bodies. Default true. */
15
+ requestBody?: boolean;
16
+ /** Capture response bodies. Default true. */
17
+ responseBody?: boolean;
18
+ /** Capture the safe subset of request and response headers. Default true. */
19
+ headers?: boolean;
20
+ /** Per-body ceiling before clipping, in bytes. Default 8 KB. */
21
+ maxBodyBytes?: number;
22
+ /** Extra key names to drop, on top of the built-in credential list. */
23
+ redactKeys?: string[];
24
+ }
25
+ export type ResolvedCaptureConfig = Required<Omit<TellannCaptureConfig, 'redactKeys'>> & {
26
+ redactKeys: string[];
27
+ };
28
+ export declare function resolveCaptureConfig(config?: TellannCaptureConfig): ResolvedCaptureConfig;
29
+ export declare function isSecretKey(key: string, extra?: string[]): boolean;
30
+ /**
31
+ * Copies a payload, dropping credential-shaped fields and clipping the result.
32
+ *
33
+ * Returns `undefined` when there is nothing worth sending, so the caller can
34
+ * leave the field off the event entirely rather than send an empty object.
35
+ */
36
+ export declare function sanitizePayload(value: unknown, capture: ResolvedCaptureConfig): unknown;
37
+ /**
38
+ * Keeps a sanitized payload inside its byte budget.
39
+ *
40
+ * A body over budget is replaced by a description of itself rather than a
41
+ * half-serialized fragment: a truncated JSON string reads as corrupt data in
42
+ * the run, while "an object with these keys, this big" is still useful.
43
+ */
44
+ export declare function clipToBudget(value: unknown, maxBytes: number): unknown;
45
+ /** The safe subset of a header bag, with credential headers never included. */
46
+ export declare function sanitizeHeaders(headers: Record<string, unknown> | undefined, capture: ResolvedCaptureConfig): Record<string, string> | undefined;
47
+ /** Best-effort byte size of a payload, for the run's throughput totals. */
48
+ export declare function payloadBytes(value: unknown): number | undefined;
49
+ /** Parses a body the framework handed over as a raw string. */
50
+ export declare function parseBody(raw: unknown, contentType?: string): unknown;
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ /**
3
+ * What a captured request is allowed to carry out of the application process.
4
+ *
5
+ * Two independent protections apply to a QA run's payloads. This module is the
6
+ * first: anything that looks like a credential never leaves the server at all,
7
+ * and bodies are clipped so one large upload cannot push an event past the
8
+ * collector's size limit. The second lives in the desktop and the ingestion
9
+ * pipeline, which classify every remaining leaf and encrypt it at rest.
10
+ *
11
+ * Doing it here as well matters because the value would otherwise sit in a
12
+ * relay buffer, a spool file and a log line before the classifier ever sees it.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.resolveCaptureConfig = resolveCaptureConfig;
16
+ exports.isSecretKey = isSecretKey;
17
+ exports.sanitizePayload = sanitizePayload;
18
+ exports.clipToBudget = clipToBudget;
19
+ exports.sanitizeHeaders = sanitizeHeaders;
20
+ exports.payloadBytes = payloadBytes;
21
+ exports.parseBody = parseBody;
22
+ const DEFAULT_MAX_BODY_BYTES = 8 * 1024;
23
+ function resolveCaptureConfig(config) {
24
+ return {
25
+ requestBody: config?.requestBody ?? true,
26
+ responseBody: config?.responseBody ?? true,
27
+ headers: config?.headers ?? true,
28
+ maxBodyBytes: config?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
29
+ redactKeys: (config?.redactKeys ?? []).map((key) => key.toLowerCase()),
30
+ };
31
+ }
32
+ /**
33
+ * Key names whose value is dropped outright.
34
+ *
35
+ * Token based rather than substring based: a plain substring test drops
36
+ * ordinary fields by accident (`profile` contains `file`, `company` contains
37
+ * `pan`), and a value dropped by mistake cannot be recovered from the run.
38
+ */
39
+ const SECRET_TOKENS = new Set([
40
+ 'password', 'passwd', 'passcode', 'passphrase', 'secret', 'token', 'jwt', 'bearer',
41
+ 'authorization', 'cookie', 'cookies', 'cvv', 'cvc', 'pin', 'otp', 'credential',
42
+ 'credentials', 'pan', 'salt', 'hash', 'signature',
43
+ ]);
44
+ const SECRET_PHRASES = [
45
+ 'cardnumber', 'cardnum', 'creditcard', 'debitcard', 'securitycode',
46
+ 'sessionid', 'sessiontoken', 'sessionkey', 'privatekey', 'secretkey', 'apikey',
47
+ 'accesstoken', 'refreshtoken', 'idtoken', 'clientsecret', 'setcookie',
48
+ ];
49
+ /** Request and response headers worth keeping. Everything else is dropped. */
50
+ const SAFE_HEADERS = new Set([
51
+ 'accept', 'accept-encoding', 'accept-language', 'content-type', 'content-length',
52
+ 'host', 'origin', 'referer', 'user-agent', 'x-request-id', 'x-requested-with',
53
+ 'x-forwarded-proto', 'cache-control', 'etag', 'location', 'retry-after',
54
+ ]);
55
+ function tokensOf(key) {
56
+ return key
57
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
58
+ .split(/[^A-Za-z0-9]+/)
59
+ .filter(Boolean)
60
+ .map((token) => token.toLowerCase());
61
+ }
62
+ function isSecretKey(key, extra = []) {
63
+ const lower = key.toLowerCase();
64
+ if (extra.includes(lower))
65
+ return true;
66
+ const tokens = tokensOf(key);
67
+ if (tokens.some((token) => SECRET_TOKENS.has(token)))
68
+ return true;
69
+ const joined = tokens.join('');
70
+ return SECRET_PHRASES.some((phrase) => joined.includes(phrase));
71
+ }
72
+ /**
73
+ * Copies a payload, dropping credential-shaped fields and clipping the result.
74
+ *
75
+ * Returns `undefined` when there is nothing worth sending, so the caller can
76
+ * leave the field off the event entirely rather than send an empty object.
77
+ */
78
+ function sanitizePayload(value, capture) {
79
+ if (value === undefined || value === null)
80
+ return undefined;
81
+ const visit = (child, key, depth) => {
82
+ if (depth > 8)
83
+ return '[TRUNCATED]';
84
+ if (child === null || typeof child === 'boolean' || typeof child === 'number')
85
+ return child;
86
+ if (typeof child === 'string') {
87
+ if (key && isSecretKey(key, capture.redactKeys))
88
+ return '[REDACTED]';
89
+ return child.length > 4_096 ? `${child.slice(0, 4_096)}…[TRUNCATED]` : child;
90
+ }
91
+ if (Array.isArray(child))
92
+ return child.slice(0, 100).map((item, index) => visit(item, `${key}.${index}`, depth + 1));
93
+ if (typeof child === 'object') {
94
+ if (Buffer.isBuffer(child))
95
+ return `[BINARY · ${child.length} bytes]`;
96
+ return Object.fromEntries(Object.entries(child).slice(0, 100).map(([childKey, item]) => [
97
+ childKey,
98
+ isSecretKey(childKey, capture.redactKeys) ? '[REDACTED]' : visit(item, childKey, depth + 1),
99
+ ]));
100
+ }
101
+ return String(child).slice(0, 2_000);
102
+ };
103
+ const sanitized = visit(value, '', 0);
104
+ return clipToBudget(sanitized, capture.maxBodyBytes);
105
+ }
106
+ /**
107
+ * Keeps a sanitized payload inside its byte budget.
108
+ *
109
+ * A body over budget is replaced by a description of itself rather than a
110
+ * half-serialized fragment: a truncated JSON string reads as corrupt data in
111
+ * the run, while "an object with these keys, this big" is still useful.
112
+ */
113
+ function clipToBudget(value, maxBytes) {
114
+ let serialized;
115
+ try {
116
+ serialized = JSON.stringify(value) ?? '';
117
+ }
118
+ catch {
119
+ return '[UNSERIALIZABLE]';
120
+ }
121
+ if (Buffer.byteLength(serialized, 'utf8') <= maxBytes)
122
+ return value;
123
+ if (typeof value === 'string')
124
+ return `${value.slice(0, maxBytes)}…[TRUNCATED]`;
125
+ if (Array.isArray(value)) {
126
+ return { truncated: true, kind: 'array', length: value.length, bytes: Buffer.byteLength(serialized, 'utf8') };
127
+ }
128
+ if (value && typeof value === 'object') {
129
+ return {
130
+ truncated: true,
131
+ kind: 'object',
132
+ keys: Object.keys(value).slice(0, 50),
133
+ bytes: Buffer.byteLength(serialized, 'utf8'),
134
+ };
135
+ }
136
+ return '[TRUNCATED]';
137
+ }
138
+ /** The safe subset of a header bag, with credential headers never included. */
139
+ function sanitizeHeaders(headers, capture) {
140
+ if (!capture.headers || !headers)
141
+ return undefined;
142
+ const entries = Object.entries(headers)
143
+ .filter(([key]) => SAFE_HEADERS.has(key.toLowerCase()) && !isSecretKey(key, capture.redactKeys))
144
+ .slice(0, 40)
145
+ .map(([key, value]) => [
146
+ key.toLowerCase(),
147
+ String(Array.isArray(value) ? value.join(', ') : value ?? '').slice(0, 500),
148
+ ]);
149
+ return entries.length ? Object.fromEntries(entries) : undefined;
150
+ }
151
+ /** Best-effort byte size of a payload, for the run's throughput totals. */
152
+ function payloadBytes(value) {
153
+ if (value === undefined || value === null)
154
+ return undefined;
155
+ if (typeof value === 'string')
156
+ return Buffer.byteLength(value, 'utf8');
157
+ if (Buffer.isBuffer(value))
158
+ return value.length;
159
+ try {
160
+ return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');
161
+ }
162
+ catch {
163
+ return undefined;
164
+ }
165
+ }
166
+ /** Parses a body the framework handed over as a raw string. */
167
+ function parseBody(raw, contentType = '') {
168
+ if (raw === undefined || raw === null)
169
+ return undefined;
170
+ if (typeof raw !== 'string')
171
+ return raw;
172
+ if (!raw)
173
+ return undefined;
174
+ if (/json/i.test(contentType) || /^[[{]/.test(raw.trim())) {
175
+ try {
176
+ return JSON.parse(raw);
177
+ }
178
+ catch {
179
+ return raw;
180
+ }
181
+ }
182
+ if (/application\/x-www-form-urlencoded/i.test(contentType)) {
183
+ return Object.fromEntries(new URLSearchParams(raw).entries());
184
+ }
185
+ return raw;
186
+ }
@@ -7,5 +7,13 @@ export interface CaptureErrorOptions {
7
7
  eventType?: 'SERVER_ERROR' | 'ERROR_OCCURRED';
8
8
  runId?: string;
9
9
  traceId?: string;
10
+ /**
11
+ * The route the error came from. Reported alongside the error rather than
12
+ * only inside `context`, because a QA run groups server errors by route and
13
+ * cannot go looking for it in a free-form bag.
14
+ */
15
+ route?: string;
16
+ method?: string;
17
+ statusCode?: number;
10
18
  }
11
19
  export declare function captureErrorEvent(config: TellannBackendConfig, options: CaptureErrorOptions): Promise<void>;
@@ -2,17 +2,20 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.captureErrorEvent = captureErrorEvent;
4
4
  const uuid_1 = require("uuid");
5
+ const requestContext_1 = require("./requestContext");
6
+ const qaEvidence_1 = require("./qaEvidence");
5
7
  const MAX_EVENT_SIZE_BYTES = 32 * 1024; // 32 KB limit
6
8
  async function captureErrorEvent(config, options) {
7
9
  const err = options.error instanceof Error ? options.error : new Error(String(options.error));
10
+ const requestContext = (0, requestContext_1.currentRequestContext)();
8
11
  const event = {
9
12
  eventId: (0, uuid_1.v4)(),
10
- sessionId: options.sessionId ?? config.sessionId ?? (0, uuid_1.v4)(),
13
+ sessionId: options.sessionId ?? requestContext?.sessionId ?? config.sessionId ?? (0, uuid_1.v4)(),
11
14
  tenantId: config.tenantId ?? 'unknown',
12
15
  applicationId: config.applicationId,
13
16
  environmentId: config.environmentId ?? null,
14
- runId: options.runId ?? config.runId ?? null,
15
- traceId: options.traceId ?? config.traceId ?? null,
17
+ runId: options.runId ?? requestContext?.runId ?? config.runId ?? null,
18
+ traceId: options.traceId ?? requestContext?.traceId ?? config.traceId ?? null,
16
19
  agentVersion: config.agentVersion ?? null,
17
20
  instrumentationManifestVersion: config.instrumentationManifestVersion ?? null,
18
21
  source: 'backend-sdk',
@@ -23,6 +26,9 @@ async function captureErrorEvent(config, options) {
23
26
  message: err.message,
24
27
  stack: err.stack ?? null,
25
28
  name: err.name,
29
+ route: options.route ?? requestContext?.route ?? null,
30
+ method: options.method ?? requestContext?.method ?? null,
31
+ statusCode: options.statusCode ?? null,
26
32
  context: options.context ?? {},
27
33
  },
28
34
  };
@@ -47,10 +53,10 @@ async function captureErrorEvent(config, options) {
47
53
  if (config.environmentId) {
48
54
  headers['x-tellann-environment-id'] = config.environmentId;
49
55
  }
50
- if (config.runId)
51
- headers['x-tellann-run-id'] = config.runId;
52
- if (config.traceId)
53
- headers['x-tellann-trace-id'] = config.traceId;
56
+ if (event.runId)
57
+ headers['x-tellann-run-id'] = event.runId;
58
+ if (event.traceId)
59
+ headers['x-tellann-trace-id'] = event.traceId;
54
60
  await fetch(`${config.endpoint}/v1/events`, {
55
61
  method: 'POST',
56
62
  headers,
@@ -60,4 +66,10 @@ async function captureErrorEvent(config, options) {
60
66
  catch {
61
67
  // Silently swallow
62
68
  }
69
+ await (0, qaEvidence_1.postQaEvidence)(config, {
70
+ eventType: 'QA_BACKEND_ERROR',
71
+ metadata: event.metadata,
72
+ traceId: event.traceId,
73
+ runId: event.runId,
74
+ });
63
75
  }
@@ -0,0 +1,31 @@
1
+ import type { TellannBackendConfig } from './TELLANN';
2
+ /**
3
+ * The desktop app's local relay only ever binds to loopback (see
4
+ * `LocalRunRelay.start`), so an endpoint pointed anywhere else is the
5
+ * standing, environment-scoped gateway a deployed server is configured with
6
+ * once and never has to change per run.
7
+ */
8
+ export declare function isLocalRelayEndpoint(endpoint: string): boolean;
9
+ export declare function nextEvidenceEnvelope(): {
10
+ sessionId: string;
11
+ localSequence: number;
12
+ };
13
+ export interface QaEvidencePost {
14
+ eventType: 'QA_BACKEND_REQUEST' | 'QA_BACKEND_ERROR' | 'QA_BACKEND_DATA_ACCESS';
15
+ metadata: Record<string, unknown>;
16
+ traceId?: string | null;
17
+ runId?: string | null;
18
+ }
19
+ /**
20
+ * Posts one backend evidence event through the environment's standing
21
+ * ingestion key rather than a credential scoped to a single run.
22
+ *
23
+ * Which run this lands on is resolved server-side, against whichever run is
24
+ * currently recording for this environment — see
25
+ * `POST /environments/:environmentId/qa-evidence/batch` in onboarding-api.
26
+ * Silently a no-op when the config carries no `apiKey`/`environmentId`
27
+ * (nothing configured to post through) or when the endpoint is the desktop's
28
+ * own local relay (which already carries backend evidence its own way, over
29
+ * `/v1/events`).
30
+ */
31
+ export declare function postQaEvidence(config: TellannBackendConfig, input: QaEvidencePost): Promise<void>;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isLocalRelayEndpoint = isLocalRelayEndpoint;
4
+ exports.nextEvidenceEnvelope = nextEvidenceEnvelope;
5
+ exports.postQaEvidence = postQaEvidence;
6
+ const uuid_1 = require("uuid");
7
+ /**
8
+ * The desktop app's local relay only ever binds to loopback (see
9
+ * `LocalRunRelay.start`), so an endpoint pointed anywhere else is the
10
+ * standing, environment-scoped gateway a deployed server is configured with
11
+ * once and never has to change per run.
12
+ */
13
+ function isLocalRelayEndpoint(endpoint) {
14
+ try {
15
+ return new URL(endpoint).hostname === '127.0.0.1';
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ /**
22
+ * One evidence session per process, not per event.
23
+ *
24
+ * `QAEvidenceEventSchema` groups events by `(sessionId, localSequence)` the
25
+ * same way the desktop's own recorder does; a process that lives for the
26
+ * lifetime of a request or a worker keeps one sequence for as long as it
27
+ * runs, so `localSequence` only has to be unique within that lifetime.
28
+ */
29
+ let evidenceSessionId = null;
30
+ let evidenceSequence = 0;
31
+ function nextEvidenceEnvelope() {
32
+ if (!evidenceSessionId)
33
+ evidenceSessionId = (0, uuid_1.v4)();
34
+ evidenceSequence += 1;
35
+ return { sessionId: evidenceSessionId, localSequence: evidenceSequence };
36
+ }
37
+ /**
38
+ * Posts one backend evidence event through the environment's standing
39
+ * ingestion key rather than a credential scoped to a single run.
40
+ *
41
+ * Which run this lands on is resolved server-side, against whichever run is
42
+ * currently recording for this environment — see
43
+ * `POST /environments/:environmentId/qa-evidence/batch` in onboarding-api.
44
+ * Silently a no-op when the config carries no `apiKey`/`environmentId`
45
+ * (nothing configured to post through) or when the endpoint is the desktop's
46
+ * own local relay (which already carries backend evidence its own way, over
47
+ * `/v1/events`).
48
+ */
49
+ async function postQaEvidence(config, input) {
50
+ if (!config.apiKey || !config.environmentId || isLocalRelayEndpoint(config.endpoint))
51
+ return;
52
+ const envelope = nextEvidenceEnvelope();
53
+ const event = {
54
+ schemaVersion: '2.0',
55
+ eventId: (0, uuid_1.v4)(),
56
+ sessionId: envelope.sessionId,
57
+ traceId: input.traceId ?? config.traceId ?? null,
58
+ applicationId: config.applicationId,
59
+ environmentId: config.environmentId,
60
+ localSequence: envelope.localSequence,
61
+ timestamp: new Date().toISOString(),
62
+ eventType: input.eventType,
63
+ source: 'BACKEND_SDK',
64
+ scope: 'PRE_BOUNDARY',
65
+ metadata: input.metadata,
66
+ protectedValues: [],
67
+ ...(input.runId ?? config.runId ? { runId: input.runId ?? config.runId } : {}),
68
+ };
69
+ try {
70
+ await fetch(`${config.endpoint}/environments/${config.environmentId}/qa-evidence/batch`, {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` },
73
+ body: JSON.stringify({ events: [event] }),
74
+ });
75
+ }
76
+ catch {
77
+ // Telemetry never fails the operation it describes.
78
+ }
79
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * One persistence operation observed while a request was in flight.
3
+ */
4
+ export interface TellannDataAccess {
5
+ model: string;
6
+ operation: string;
7
+ records?: number | null;
8
+ durationMs?: number | null;
9
+ mutation?: boolean;
10
+ }
11
+ export interface TellannRequestContext {
12
+ sessionId?: string;
13
+ runId?: string;
14
+ traceId?: string;
15
+ method?: string;
16
+ route?: string;
17
+ /** Filled in as the request runs, then attached to its API_REQUEST event. */
18
+ dataAccess: TellannDataAccess[];
19
+ }
20
+ export declare function runInRequestContext<T>(context: TellannRequestContext, callback: () => T): T;
21
+ export declare function currentRequestContext(): TellannRequestContext | undefined;
22
+ /**
23
+ * Enters a context without a callback to wrap.
24
+ *
25
+ * Koa and Express middleware wrap what comes after them, so they can use
26
+ * `runInRequestContext`. Fastify and hapi hooks do not: they run, return, and
27
+ * the framework carries on by itself. `enterWith` binds the context to the
28
+ * current asynchronous execution instead, which is how those hooks reach the
29
+ * handlers the framework runs after them.
30
+ */
31
+ export declare function enterRequestContext(context: TellannRequestContext): TellannRequestContext;
32
+ /** Records one operation against the in-flight request, if there is one. */
33
+ export declare function recordDataAccess(access: TellannDataAccess): TellannRequestContext | undefined;
34
+ /**
35
+ * The models a request touched, collapsed to one entry per model and
36
+ * operation, with the record counts summed and the operations counted.
37
+ *
38
+ * Collapsing matters: a request that reads one model in a loop produces
39
+ * thousands of entries, and reporting each one would flood the run with rows
40
+ * that all say the same thing. The count keeps the volume visible without the
41
+ * noise.
42
+ */
43
+ export declare function summarizeDataAccess(entries: TellannDataAccess[]): Array<{
44
+ model: string;
45
+ operation: string;
46
+ records: number | null;
47
+ count: number;
48
+ mutation: boolean;
49
+ }>;
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runInRequestContext = runInRequestContext;
4
+ exports.currentRequestContext = currentRequestContext;
5
+ exports.enterRequestContext = enterRequestContext;
6
+ exports.recordDataAccess = recordDataAccess;
7
+ exports.summarizeDataAccess = summarizeDataAccess;
8
+ const node_async_hooks_1 = require("node:async_hooks");
9
+ /**
10
+ * Ties a persistence operation to the request that caused it.
11
+ *
12
+ * `AsyncLocalStorage` is what makes "which models did this endpoint touch"
13
+ * answerable without the application passing a context object down through
14
+ * every layer: the integration opens a store per request, and an ORM hook
15
+ * anywhere below it lands in the same store no matter how many awaits deep it
16
+ * is. Work that escapes the request - a queued job, a detached promise - lands
17
+ * in no store at all and is reported without a route, which is correct: it did
18
+ * not belong to that request.
19
+ */
20
+ const storage = new node_async_hooks_1.AsyncLocalStorage();
21
+ function runInRequestContext(context, callback) {
22
+ return storage.run(context, callback);
23
+ }
24
+ function currentRequestContext() {
25
+ return storage.getStore();
26
+ }
27
+ /**
28
+ * Enters a context without a callback to wrap.
29
+ *
30
+ * Koa and Express middleware wrap what comes after them, so they can use
31
+ * `runInRequestContext`. Fastify and hapi hooks do not: they run, return, and
32
+ * the framework carries on by itself. `enterWith` binds the context to the
33
+ * current asynchronous execution instead, which is how those hooks reach the
34
+ * handlers the framework runs after them.
35
+ */
36
+ function enterRequestContext(context) {
37
+ storage.enterWith(context);
38
+ return context;
39
+ }
40
+ /** Records one operation against the in-flight request, if there is one. */
41
+ function recordDataAccess(access) {
42
+ const context = storage.getStore();
43
+ if (!context)
44
+ return undefined;
45
+ // A request in a loop can touch one model thousands of times; the run needs
46
+ // the shape of what happened, not an unbounded list.
47
+ if (context.dataAccess.length < 200)
48
+ context.dataAccess.push(access);
49
+ return context;
50
+ }
51
+ /**
52
+ * The models a request touched, collapsed to one entry per model and
53
+ * operation, with the record counts summed and the operations counted.
54
+ *
55
+ * Collapsing matters: a request that reads one model in a loop produces
56
+ * thousands of entries, and reporting each one would flood the run with rows
57
+ * that all say the same thing. The count keeps the volume visible without the
58
+ * noise.
59
+ */
60
+ function summarizeDataAccess(entries) {
61
+ const totals = new Map();
62
+ for (const entry of entries) {
63
+ const key = `${entry.model}:${entry.operation}`;
64
+ const existing = totals.get(key);
65
+ if (!existing) {
66
+ totals.set(key, {
67
+ model: entry.model,
68
+ operation: entry.operation,
69
+ records: entry.records ?? null,
70
+ count: 1,
71
+ mutation: Boolean(entry.mutation),
72
+ });
73
+ continue;
74
+ }
75
+ existing.count += 1;
76
+ if (entry.records != null)
77
+ existing.records = (existing.records ?? 0) + entry.records;
78
+ }
79
+ return [...totals.values()].slice(0, 50);
80
+ }
@@ -1,14 +1,39 @@
1
1
  import { TellannBackendConfig } from './TELLANN';
2
2
  export interface TrackApiOptions {
3
+ /** The concrete path the client called. */
3
4
  endpoint: string;
4
5
  method: string;
5
6
  statusCode: number;
6
7
  durationMs: number;
8
+ /**
9
+ * The route template the framework matched, e.g. `/orders/:id`. Supplying it
10
+ * is what keeps a thousand calls to `/orders/17` from reading as a thousand
11
+ * distinct endpoints, and keeps identifiers out of the grouping key.
12
+ */
13
+ route?: string;
7
14
  /** Optional: correlate with a frontend session via X-TELLANN-Session-ID header */
8
15
  sessionId?: string;
9
16
  /** Optional: idempotency / tracing */
10
17
  requestId?: string;
11
18
  runId?: string;
12
19
  traceId?: string;
20
+ /** Parsed query parameters. Credential-shaped keys are dropped. */
21
+ query?: Record<string, unknown>;
22
+ requestBody?: unknown;
23
+ responseBody?: unknown;
24
+ requestHeaders?: Record<string, unknown>;
25
+ responseHeaders?: Record<string, unknown>;
26
+ /** The function or controller that served the request, where it is known. */
27
+ handler?: string;
28
+ framework?: string;
29
+ /**
30
+ * Models this request touched. Left unset, it is taken from whatever the
31
+ * data-access hooks recorded while the request was in flight.
32
+ */
33
+ models?: Array<{
34
+ model: string;
35
+ operation?: string;
36
+ records?: number | null;
37
+ }>;
13
38
  }
14
39
  export declare function trackApiEvent(config: TellannBackendConfig, options: TrackApiOptions): Promise<void>;