@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.
@@ -2,16 +2,39 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.trackApiEvent = trackApiEvent;
4
4
  const uuid_1 = require("uuid");
5
+ const capture_1 = require("./capture");
6
+ const requestContext_1 = require("./requestContext");
7
+ const qaEvidence_1 = require("./qaEvidence");
5
8
  const MAX_EVENT_SIZE_BYTES = 32 * 1024; // 32 KB limit
9
+ function eventBytes(event) {
10
+ try {
11
+ return Buffer.byteLength(JSON.stringify(event), 'utf8');
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
6
17
  async function trackApiEvent(config, options) {
18
+ const capture = (0, capture_1.resolveCaptureConfig)(config.capture);
19
+ const context = (0, requestContext_1.currentRequestContext)();
20
+ const models = options.models?.length
21
+ ? options.models.map((entry) => ({
22
+ model: String(entry.model),
23
+ operation: entry.operation ?? null,
24
+ records: entry.records ?? null,
25
+ }))
26
+ : (0, requestContext_1.summarizeDataAccess)(context?.dataAccess ?? []);
27
+ const requestBody = capture.requestBody ? (0, capture_1.sanitizePayload)(options.requestBody, capture) : undefined;
28
+ const responseBody = capture.responseBody ? (0, capture_1.sanitizePayload)(options.responseBody, capture) : undefined;
29
+ const query = (0, capture_1.sanitizePayload)(options.query, capture);
7
30
  const event = {
8
31
  eventId: (0, uuid_1.v4)(),
9
- sessionId: options.sessionId ?? config.sessionId ?? (0, uuid_1.v4)(),
32
+ sessionId: options.sessionId ?? context?.sessionId ?? config.sessionId ?? (0, uuid_1.v4)(),
10
33
  tenantId: config.tenantId ?? 'unknown',
11
34
  applicationId: config.applicationId,
12
35
  environmentId: config.environmentId ?? null,
13
- runId: options.runId ?? config.runId ?? null,
14
- traceId: options.traceId ?? config.traceId ?? null,
36
+ runId: options.runId ?? context?.runId ?? config.runId ?? null,
37
+ traceId: options.traceId ?? context?.traceId ?? config.traceId ?? null,
15
38
  agentVersion: config.agentVersion ?? null,
16
39
  instrumentationManifestVersion: config.instrumentationManifestVersion ?? null,
17
40
  source: 'backend-sdk',
@@ -21,24 +44,48 @@ async function trackApiEvent(config, options) {
21
44
  metadata: {
22
45
  requestId: options.requestId ?? (0, uuid_1.v4)(),
23
46
  endpoint: options.endpoint,
47
+ route: options.route ?? context?.route ?? null,
24
48
  method: options.method.toUpperCase(),
25
49
  statusCode: options.statusCode,
26
50
  durationMs: options.durationMs,
51
+ handler: options.handler ?? null,
52
+ framework: options.framework ?? null,
53
+ // Sizes are reported even when the bodies themselves are not captured,
54
+ // so a run can still show throughput for an endpoint whose payloads are
55
+ // switched off.
56
+ requestBytes: (0, capture_1.payloadBytes)(options.requestBody) ?? null,
57
+ responseBytes: (0, capture_1.payloadBytes)(options.responseBody) ?? null,
58
+ models,
59
+ ...(query === undefined ? {} : { query }),
60
+ ...(requestBody === undefined ? {} : { requestBody }),
61
+ ...(responseBody === undefined ? {} : { responseBody }),
62
+ ...(() => {
63
+ const requestHeaders = (0, capture_1.sanitizeHeaders)(options.requestHeaders, capture);
64
+ const responseHeaders = (0, capture_1.sanitizeHeaders)(options.responseHeaders, capture);
65
+ return {
66
+ ...(requestHeaders ? { requestHeaders } : {}),
67
+ ...(responseHeaders ? { responseHeaders } : {}),
68
+ };
69
+ })(),
27
70
  },
28
71
  };
29
- // Enforce Size Limit
30
- try {
31
- const eventJson = JSON.stringify(event);
32
- const eventSize = Buffer.byteLength(eventJson, 'utf8');
33
- if (eventSize > MAX_EVENT_SIZE_BYTES) {
34
- console.error(`[Tellann Backend] API request event discarded. Size (${eventSize} bytes) exceeds limit of ${MAX_EVENT_SIZE_BYTES} bytes.`);
72
+ // Enforce the size limit. An oversized event used to be dropped whole, which
73
+ // silently lost the request from the run because one of its payloads was
74
+ // large. Shedding the payloads keeps the request, its timing and its models.
75
+ const size = eventBytes(event);
76
+ if (size === null)
77
+ return;
78
+ if (size > MAX_EVENT_SIZE_BYTES) {
79
+ const metadata = event.metadata;
80
+ delete metadata.requestBody;
81
+ delete metadata.responseBody;
82
+ metadata.payloadsOmitted = 'EVENT_SIZE_LIMIT';
83
+ const reduced = eventBytes(event);
84
+ if (reduced === null || reduced > MAX_EVENT_SIZE_BYTES) {
85
+ console.error(`[Tellann Backend] API request event discarded. Size (${size} bytes) exceeds limit of ${MAX_EVENT_SIZE_BYTES} bytes.`);
35
86
  return;
36
87
  }
37
88
  }
38
- catch (err) {
39
- console.error('[Tellann Backend] Failed to compute size of API event, discarding', err);
40
- return;
41
- }
42
89
  try {
43
90
  const headers = { 'Content-Type': 'application/json' };
44
91
  if (config.apiKey) {
@@ -47,10 +94,10 @@ async function trackApiEvent(config, options) {
47
94
  if (config.environmentId) {
48
95
  headers['x-tellann-environment-id'] = config.environmentId;
49
96
  }
50
- if (config.runId)
51
- headers['x-tellann-run-id'] = config.runId;
52
- if (config.traceId)
53
- headers['x-tellann-trace-id'] = config.traceId;
97
+ if (event.runId)
98
+ headers['x-tellann-run-id'] = event.runId;
99
+ if (event.traceId)
100
+ headers['x-tellann-trace-id'] = event.traceId;
54
101
  await fetch(`${config.endpoint}/v1/events`, {
55
102
  method: 'POST',
56
103
  headers,
@@ -60,4 +107,12 @@ async function trackApiEvent(config, options) {
60
107
  catch {
61
108
  // Silently swallow
62
109
  }
110
+ // A no-op unless this process is configured with a standing ingestion key
111
+ // rather than a per-run relay credential — see `postQaEvidence`.
112
+ await (0, qaEvidence_1.postQaEvidence)(config, {
113
+ eventType: 'QA_BACKEND_REQUEST',
114
+ metadata: event.metadata,
115
+ traceId: event.traceId,
116
+ runId: event.runId,
117
+ });
63
118
  }
@@ -0,0 +1,34 @@
1
+ import type { TellannBackendConfig } from './TELLANN';
2
+ import { type TellannRequestContext } from './requestContext';
3
+ export interface TrackDataAccessOptions {
4
+ /** The model, table or collection the operation ran against. */
5
+ model: string;
6
+ /** The operation as the data layer names it: `findMany`, `UPDATE`, `save`. */
7
+ operation: string;
8
+ /** How many records it read or changed, where the data layer reports it. */
9
+ records?: number | null;
10
+ durationMs?: number | null;
11
+ /** Leave unset to infer from the operation name. */
12
+ mutation?: boolean;
13
+ sessionId?: string;
14
+ runId?: string;
15
+ traceId?: string;
16
+ }
17
+ export declare function isMutationOperation(operation: string): boolean;
18
+ /**
19
+ * Reports one persistence operation.
20
+ *
21
+ * Inside a request it is recorded and nothing is sent: the request's own
22
+ * middleware flushes one event per model and operation when the response is
23
+ * done, so a handler that reads a model in a loop produces one row rather than
24
+ * a thousand. Outside a request - a migration, a queue consumer, a management
25
+ * command - there is nothing to flush it later, so it is sent immediately.
26
+ */
27
+ export declare function trackDataAccessEvent(config: TellannBackendConfig, options: TrackDataAccessOptions): Promise<void>;
28
+ /**
29
+ * Sends one event per model and operation the request touched.
30
+ *
31
+ * Called by the framework integrations once the response is done. Safe to call
32
+ * twice: the context is emptied as it is flushed.
33
+ */
34
+ export declare function flushRequestDataAccess(config: TellannBackendConfig, context: TellannRequestContext | undefined): Promise<void>;
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isMutationOperation = isMutationOperation;
4
+ exports.trackDataAccessEvent = trackDataAccessEvent;
5
+ exports.flushRequestDataAccess = flushRequestDataAccess;
6
+ const uuid_1 = require("uuid");
7
+ const requestContext_1 = require("./requestContext");
8
+ const qaEvidence_1 = require("./qaEvidence");
9
+ const MUTATION_PATTERN = /create|update|delete|upsert|insert|write|save|remove|drop|truncate/i;
10
+ function isMutationOperation(operation) {
11
+ return MUTATION_PATTERN.test(operation);
12
+ }
13
+ /**
14
+ * Reports one persistence operation.
15
+ *
16
+ * Inside a request it is recorded and nothing is sent: the request's own
17
+ * middleware flushes one event per model and operation when the response is
18
+ * done, so a handler that reads a model in a loop produces one row rather than
19
+ * a thousand. Outside a request - a migration, a queue consumer, a management
20
+ * command - there is nothing to flush it later, so it is sent immediately.
21
+ */
22
+ async function trackDataAccessEvent(config, options) {
23
+ const mutation = options.mutation ?? isMutationOperation(options.operation);
24
+ const recorded = (0, requestContext_1.recordDataAccess)({
25
+ model: options.model,
26
+ operation: options.operation,
27
+ records: options.records ?? null,
28
+ durationMs: options.durationMs ?? null,
29
+ mutation,
30
+ });
31
+ if (recorded)
32
+ return;
33
+ await sendDataAccessEvent(config, {
34
+ model: options.model,
35
+ operation: options.operation,
36
+ records: options.records ?? null,
37
+ durationMs: options.durationMs ?? null,
38
+ mutation,
39
+ count: 1,
40
+ }, (0, requestContext_1.currentRequestContext)(), options);
41
+ }
42
+ /**
43
+ * Sends one event per model and operation the request touched.
44
+ *
45
+ * Called by the framework integrations once the response is done. Safe to call
46
+ * twice: the context is emptied as it is flushed.
47
+ */
48
+ async function flushRequestDataAccess(config, context) {
49
+ if (!context?.dataAccess.length)
50
+ return;
51
+ const summary = (0, requestContext_1.summarizeDataAccess)(context.dataAccess);
52
+ context.dataAccess = [];
53
+ await Promise.all(summary.map((entry) => sendDataAccessEvent(config, {
54
+ model: entry.model,
55
+ operation: entry.operation,
56
+ records: entry.records,
57
+ durationMs: null,
58
+ mutation: entry.mutation,
59
+ count: entry.count,
60
+ }, context, {})));
61
+ }
62
+ async function sendDataAccessEvent(config, access, context, options) {
63
+ const { model, operation, records, durationMs, mutation } = access;
64
+ const event = {
65
+ eventId: (0, uuid_1.v4)(),
66
+ sessionId: options.sessionId ?? context?.sessionId ?? config.sessionId ?? (0, uuid_1.v4)(),
67
+ tenantId: config.tenantId ?? 'unknown',
68
+ applicationId: config.applicationId,
69
+ environmentId: config.environmentId ?? null,
70
+ runId: options.runId ?? context?.runId ?? config.runId ?? null,
71
+ traceId: options.traceId ?? context?.traceId ?? config.traceId ?? null,
72
+ agentVersion: config.agentVersion ?? null,
73
+ instrumentationManifestVersion: config.instrumentationManifestVersion ?? null,
74
+ source: 'backend-sdk',
75
+ eventVersion: '1.0',
76
+ eventType: 'BUSINESS_EVENT',
77
+ timestamp: new Date().toISOString(),
78
+ metadata: {
79
+ // The desktop routes on this discriminator, the same way it routes
80
+ // client-state evidence from the frontend adapters.
81
+ businessEventType: 'QA_BACKEND_DATA_ACCESS',
82
+ model: String(model).slice(0, 120),
83
+ operation: String(operation).slice(0, 60),
84
+ records,
85
+ durationMs,
86
+ mutation,
87
+ // How many individual operations this row stands for.
88
+ count: access.count,
89
+ route: context?.route ?? null,
90
+ method: context?.method ?? null,
91
+ },
92
+ };
93
+ try {
94
+ const headers = { 'Content-Type': 'application/json' };
95
+ if (config.apiKey)
96
+ headers.Authorization = `Bearer ${config.apiKey}`;
97
+ if (config.environmentId)
98
+ headers['x-tellann-environment-id'] = config.environmentId;
99
+ if (event.runId)
100
+ headers['x-tellann-run-id'] = event.runId;
101
+ if (event.traceId)
102
+ headers['x-tellann-trace-id'] = event.traceId;
103
+ await fetch(`${config.endpoint}/v1/events`, {
104
+ method: 'POST',
105
+ headers,
106
+ body: JSON.stringify(event),
107
+ });
108
+ }
109
+ catch {
110
+ // Telemetry never fails the operation it describes.
111
+ }
112
+ await (0, qaEvidence_1.postQaEvidence)(config, {
113
+ eventType: 'QA_BACKEND_DATA_ACCESS',
114
+ metadata: event.metadata,
115
+ traceId: event.traceId,
116
+ runId: event.runId,
117
+ });
118
+ }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  import { TrackApiOptions } from './core/trackApi';
2
2
  import { CaptureErrorOptions } from './core/captureError';
3
3
  import { TrackStateOptions } from './core/trackState';
4
+ import { TrackDataAccessOptions } from './core/trackDataAccess';
4
5
  export * from './core/TELLANN';
5
6
  export type { EventType, TellannEvent } from './event-types';
6
7
  export { TrackApiOptions } from './core/trackApi';
7
8
  export { CaptureErrorOptions } from './core/captureError';
8
9
  export { TrackStateOptions } from './core/trackState';
10
+ export { TrackDataAccessOptions, isMutationOperation } from './core/trackDataAccess';
11
+ export type { TellannCaptureConfig } from './core/capture';
12
+ export { currentRequestContext, recordDataAccess, runInRequestContext, summarizeDataAccess, } from './core/requestContext';
13
+ export type { TellannDataAccess, TellannRequestContext } from './core/requestContext';
9
14
  export * from './integrations/express';
10
15
  export * from './integrations/fastify';
16
+ export * from './integrations/koa';
17
+ export * from './integrations/hapi';
18
+ export * from './integrations/prisma';
11
19
  /**
12
20
  * Backward compatible helper to track an API call using the initialized TELLANN singleton.
13
21
  */
@@ -20,3 +28,8 @@ export declare function captureError(options: CaptureErrorOptions): Promise<void
20
28
  * Backward compatible helper to track a state transition using the initialized TELLANN singleton.
21
29
  */
22
30
  export declare function trackState(options: TrackStateOptions): Promise<void>;
31
+ /**
32
+ * Backward compatible helper to report a persistence operation using the
33
+ * initialized TELLANN singleton.
34
+ */
35
+ export declare function trackDataAccess(options: TrackDataAccessOptions): Promise<void>;
package/dist/index.js CHANGED
@@ -14,13 +14,25 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.summarizeDataAccess = exports.runInRequestContext = exports.recordDataAccess = exports.currentRequestContext = exports.isMutationOperation = void 0;
17
18
  exports.trackApi = trackApi;
18
19
  exports.captureError = captureError;
19
20
  exports.trackState = trackState;
21
+ exports.trackDataAccess = trackDataAccess;
20
22
  const TELLANN_1 = require("./core/TELLANN");
21
23
  __exportStar(require("./core/TELLANN"), exports);
24
+ var trackDataAccess_1 = require("./core/trackDataAccess");
25
+ Object.defineProperty(exports, "isMutationOperation", { enumerable: true, get: function () { return trackDataAccess_1.isMutationOperation; } });
26
+ var requestContext_1 = require("./core/requestContext");
27
+ Object.defineProperty(exports, "currentRequestContext", { enumerable: true, get: function () { return requestContext_1.currentRequestContext; } });
28
+ Object.defineProperty(exports, "recordDataAccess", { enumerable: true, get: function () { return requestContext_1.recordDataAccess; } });
29
+ Object.defineProperty(exports, "runInRequestContext", { enumerable: true, get: function () { return requestContext_1.runInRequestContext; } });
30
+ Object.defineProperty(exports, "summarizeDataAccess", { enumerable: true, get: function () { return requestContext_1.summarizeDataAccess; } });
22
31
  __exportStar(require("./integrations/express"), exports);
23
32
  __exportStar(require("./integrations/fastify"), exports);
33
+ __exportStar(require("./integrations/koa"), exports);
34
+ __exportStar(require("./integrations/hapi"), exports);
35
+ __exportStar(require("./integrations/prisma"), exports);
24
36
  /**
25
37
  * Backward compatible helper to track an API call using the initialized TELLANN singleton.
26
38
  */
@@ -39,3 +51,10 @@ async function captureError(options) {
39
51
  async function trackState(options) {
40
52
  await TELLANN_1.TELLANN.trackState(options);
41
53
  }
54
+ /**
55
+ * Backward compatible helper to report a persistence operation using the
56
+ * initialized TELLANN singleton.
57
+ */
58
+ async function trackDataAccess(options) {
59
+ await TELLANN_1.TELLANN.trackDataAccess(options);
60
+ }
@@ -1,4 +1,4 @@
1
- import type { ErrorRequestHandler, RequestHandler } from 'express';
1
+ import type { Request, ErrorRequestHandler, RequestHandler } from 'express';
2
2
  declare global {
3
3
  namespace Express {
4
4
  interface Request {
@@ -16,11 +16,23 @@ export declare function extractCorrelationContext(headers: Record<string, any>):
16
16
  runId?: string;
17
17
  traceId?: string;
18
18
  };
19
+ /**
20
+ * The route template Express matched, e.g. `/orders/:id`.
21
+ *
22
+ * `req.route` is only populated once a route handler has run, and a router
23
+ * mounted under a prefix reports its own path, so the mount point is prepended.
24
+ * When nothing matched - a 404, or an error thrown in middleware - there is no
25
+ * template, and the caller falls back to the concrete path.
26
+ */
27
+ export declare function expressRouteTemplate(req: Request): string | undefined;
19
28
  /**
20
29
  * Express middleware that automatically tracks every API request and hydrates req.tellann context.
21
30
  *
22
31
  * The middleware reads the `X-TELLANN-Session-ID` or W3C `traceparent` header to correlate
23
32
  * backend API calls with the originating frontend session.
33
+ *
34
+ * Register it before the body parser and the routes: it opens the per-request
35
+ * context that the ORM hooks report into, and the handlers run inside it.
24
36
  */
25
37
  export declare function tellannExpressMiddleware(): RequestHandler;
26
38
  /**
@@ -3,9 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.expressMiddleware = void 0;
4
4
  exports.extractSessionId = extractSessionId;
5
5
  exports.extractCorrelationContext = extractCorrelationContext;
6
+ exports.expressRouteTemplate = expressRouteTemplate;
6
7
  exports.tellannExpressMiddleware = tellannExpressMiddleware;
7
8
  exports.tellannExpressErrorHandler = tellannExpressErrorHandler;
8
9
  const TELLANN_1 = require("../../core/TELLANN");
10
+ const requestContext_1 = require("../../core/requestContext");
9
11
  function extractSessionId(headers) {
10
12
  if (headers['x-tellann-session-id'] || headers['x-tellann-session-id']) {
11
13
  return (headers['x-tellann-session-id'] || headers['x-tellann-session-id']);
@@ -29,11 +31,56 @@ function extractCorrelationContext(headers) {
29
31
  traceId,
30
32
  };
31
33
  }
34
+ /**
35
+ * The route template Express matched, e.g. `/orders/:id`.
36
+ *
37
+ * `req.route` is only populated once a route handler has run, and a router
38
+ * mounted under a prefix reports its own path, so the mount point is prepended.
39
+ * When nothing matched - a 404, or an error thrown in middleware - there is no
40
+ * template, and the caller falls back to the concrete path.
41
+ */
42
+ function expressRouteTemplate(req) {
43
+ const route = req.route?.path;
44
+ if (!route)
45
+ return undefined;
46
+ const base = req.baseUrl ?? '';
47
+ const joined = `${base}${route}`.replace(/\/{2,}/g, '/');
48
+ return joined === '' ? '/' : joined;
49
+ }
50
+ /**
51
+ * Captures a response body without changing what the client receives.
52
+ *
53
+ * `res.json` and `res.send` are wrapped rather than the socket being tapped:
54
+ * the wrapper sees the value the application passed, which is what a QA run
55
+ * should show, and it avoids buffering streamed or piped responses.
56
+ */
57
+ function captureResponseBody(res, onBody) {
58
+ const json = res.json.bind(res);
59
+ const send = res.send.bind(res);
60
+ let captured = false;
61
+ res.json = ((body) => {
62
+ if (!captured) {
63
+ captured = true;
64
+ onBody(body);
65
+ }
66
+ return json(body);
67
+ });
68
+ res.send = ((body) => {
69
+ if (!captured) {
70
+ captured = true;
71
+ onBody(body);
72
+ }
73
+ return send(body);
74
+ });
75
+ }
32
76
  /**
33
77
  * Express middleware that automatically tracks every API request and hydrates req.tellann context.
34
78
  *
35
79
  * The middleware reads the `X-TELLANN-Session-ID` or W3C `traceparent` header to correlate
36
80
  * backend API calls with the originating frontend session.
81
+ *
82
+ * Register it before the body parser and the routes: it opens the per-request
83
+ * context that the ORM hooks report into, and the handlers run inside it.
37
84
  */
38
85
  function tellannExpressMiddleware() {
39
86
  return (req, res, next) => {
@@ -43,9 +90,26 @@ function tellannExpressMiddleware() {
43
90
  const requestId = req.headers['x-request-id'];
44
91
  // Decorate request object
45
92
  req.tellann = correlation;
93
+ let responseBody;
94
+ captureResponseBody(res, (body) => { responseBody = body; });
95
+ // Held explicitly rather than read back from async storage at report time.
96
+ // `finish` is emitted by the socket, and a listener does not inherit the
97
+ // context it was registered in, so relying on the store here would lose
98
+ // the models on exactly the streamed responses that take longest.
99
+ const context = {
100
+ ...correlation,
101
+ method: req.method,
102
+ // The template is not known until a route matches, so the context
103
+ // starts with the concrete path and is corrected once one does.
104
+ route: req.originalUrl?.split('?')[0] ?? req.path,
105
+ dataAccess: [],
106
+ };
46
107
  res.on('finish', () => {
108
+ context.route = expressRouteTemplate(req) ?? context.route;
109
+ const models = (0, requestContext_1.summarizeDataAccess)(context.dataAccess);
47
110
  TELLANN_1.TELLANN.trackApi({
48
- endpoint: req.path,
111
+ endpoint: req.originalUrl?.split('?')[0] ?? req.path,
112
+ route: expressRouteTemplate(req),
49
113
  method: req.method,
50
114
  statusCode: res.statusCode,
51
115
  durationMs: Date.now() - start,
@@ -53,9 +117,19 @@ function tellannExpressMiddleware() {
53
117
  requestId,
54
118
  runId: correlation.runId,
55
119
  traceId: correlation.traceId,
120
+ framework: 'express',
121
+ models,
122
+ query: req.query,
123
+ // `req.body` is whatever the body parser produced. With no parser
124
+ // registered it is undefined, and the request is reported without one.
125
+ requestBody: req.body,
126
+ responseBody,
127
+ requestHeaders: req.headers,
128
+ responseHeaders: res.getHeaders(),
56
129
  });
130
+ void TELLANN_1.TELLANN.flushDataAccess(context);
57
131
  });
58
- next();
132
+ (0, requestContext_1.runInRequestContext)(context, () => next());
59
133
  };
60
134
  }
61
135
  /**
@@ -72,6 +146,7 @@ function tellannExpressErrorHandler() {
72
146
  traceId: req.tellann?.traceId,
73
147
  context: {
74
148
  path: req.path,
149
+ route: expressRouteTemplate(req),
75
150
  method: req.method,
76
151
  query: req.query,
77
152
  },
@@ -7,6 +7,7 @@ exports.fastifyPlugin = exports.tellannFastifyPlugin = void 0;
7
7
  const fastify_plugin_1 = __importDefault(require("fastify-plugin"));
8
8
  const TELLANN_1 = require("../../core/TELLANN");
9
9
  const express_1 = require("../express");
10
+ const requestContext_1 = require("../../core/requestContext");
10
11
  /**
11
12
  * Fastify plugin that automatically tracks every API request and handles error correlation.
12
13
  *
@@ -21,13 +22,32 @@ const tellannFastifyPluginImpl = async (fastify) => {
21
22
  // Add preHandler to extract session metadata
22
23
  fastify.addHook('onRequest', async (request) => {
23
24
  request.tellann = (0, express_1.extractCorrelationContext)(request.headers);
25
+ // Kept on the request as well as in async storage: the `onResponse` hook
26
+ // runs after the response is out, where the store is no longer reliable.
27
+ request.tellannContext =
28
+ (0, requestContext_1.enterRequestContext)({
29
+ ...request.tellann,
30
+ method: request.method,
31
+ route: request.routeOptions?.url ?? request.url.split('?')[0],
32
+ dataAccess: [],
33
+ });
34
+ });
35
+ // The payload is only available on `onSend`, and it is the serialized body
36
+ // rather than the value the handler returned, so it is parsed back for the
37
+ // run to display. Nothing is changed on the way through.
38
+ fastify.addHook('onSend', async (request, _reply, payload) => {
39
+ request.tellannResponseBody = payload;
40
+ return payload;
24
41
  });
25
42
  // Track API completion
26
43
  fastify.addHook('onResponse', async (request, reply) => {
27
44
  const sessionId = request.tellann?.sessionId;
28
45
  const requestId = request.headers['x-request-id'];
46
+ const rawBody = request.tellannResponseBody;
47
+ const context = request.tellannContext;
29
48
  await TELLANN_1.TELLANN.trackApi({
30
- endpoint: request.routeOptions?.url ?? request.url,
49
+ endpoint: request.url.split('?')[0],
50
+ route: request.routeOptions?.url ?? request.url.split('?')[0],
31
51
  method: request.method,
32
52
  statusCode: reply.statusCode,
33
53
  durationMs: Math.round(reply.elapsedTime),
@@ -35,7 +55,22 @@ const tellannFastifyPluginImpl = async (fastify) => {
35
55
  requestId,
36
56
  runId: request.tellann?.runId,
37
57
  traceId: request.tellann?.traceId,
58
+ framework: 'fastify',
59
+ models: (0, requestContext_1.summarizeDataAccess)(context?.dataAccess ?? []),
60
+ query: request.query,
61
+ requestBody: request.body,
62
+ responseBody: typeof rawBody === 'string'
63
+ ? (() => { try {
64
+ return JSON.parse(rawBody);
65
+ }
66
+ catch {
67
+ return rawBody;
68
+ } })()
69
+ : rawBody,
70
+ requestHeaders: request.headers,
71
+ responseHeaders: reply.getHeaders(),
38
72
  });
73
+ await TELLANN_1.TELLANN.flushDataAccess(context);
39
74
  });
40
75
  // Track errors
41
76
  fastify.addHook('onError', async (request, reply, error) => {
@@ -46,6 +81,9 @@ const tellannFastifyPluginImpl = async (fastify) => {
46
81
  eventType: 'SERVER_ERROR',
47
82
  runId: request.tellann?.runId,
48
83
  traceId: request.tellann?.traceId,
84
+ route: request.routeOptions?.url ?? request.url.split('?')[0],
85
+ method: request.method,
86
+ statusCode: reply.statusCode,
49
87
  context: {
50
88
  url: request.url,
51
89
  method: request.method,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * hapi integration, written as a hapi plugin.
3
+ *
4
+ * As with the Koa integration, hapi's types are described structurally so that
5
+ * this SDK never requires `@hapi/hapi` to be installed in a project that does
6
+ * not use it.
7
+ */
8
+ export type TellannHapiRequest = {
9
+ method: string;
10
+ path: string;
11
+ headers: Record<string, any>;
12
+ query?: Record<string, unknown>;
13
+ payload?: unknown;
14
+ /** The route table entry; its `path` is the pattern, not the request path. */
15
+ route?: {
16
+ path?: string;
17
+ };
18
+ response?: {
19
+ statusCode?: number;
20
+ isBoom?: boolean;
21
+ source?: unknown;
22
+ headers?: Record<string, any>;
23
+ output?: {
24
+ statusCode?: number;
25
+ };
26
+ };
27
+ app: Record<string, any>;
28
+ info?: {
29
+ received?: number;
30
+ };
31
+ };
32
+ export type TellannHapiServer = {
33
+ ext(event: string, handler: (request: TellannHapiRequest, h: any) => any): void;
34
+ events?: {
35
+ on(name: string, handler: (...args: any[]) => void): void;
36
+ };
37
+ };
38
+ /**
39
+ * The hapi plugin object, registered with `await server.register(tellannHapiPlugin)`.
40
+ */
41
+ export declare const tellannHapiPlugin: {
42
+ name: string;
43
+ version: string;
44
+ register(server: TellannHapiServer): void;
45
+ };