@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.
- package/dist/core/TELLANN.d.ts +19 -0
- package/dist/core/TELLANN.js +19 -0
- package/dist/core/capture.d.ts +50 -0
- package/dist/core/capture.js +186 -0
- package/dist/core/captureError.d.ts +8 -0
- package/dist/core/captureError.js +19 -7
- package/dist/core/qaEvidence.d.ts +31 -0
- package/dist/core/qaEvidence.js +79 -0
- package/dist/core/requestContext.d.ts +49 -0
- package/dist/core/requestContext.js +80 -0
- package/dist/core/trackApi.d.ts +25 -0
- package/dist/core/trackApi.js +72 -17
- package/dist/core/trackDataAccess.d.ts +34 -0
- package/dist/core/trackDataAccess.js +118 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +19 -0
- package/dist/integrations/express/index.d.ts +13 -1
- package/dist/integrations/express/index.js +77 -2
- package/dist/integrations/fastify/index.js +39 -1
- package/dist/integrations/hapi/index.d.ts +45 -0
- package/dist/integrations/hapi/index.js +75 -0
- package/dist/integrations/koa/index.d.ts +34 -0
- package/dist/integrations/koa/index.js +65 -0
- package/dist/integrations/prisma/index.d.ts +46 -0
- package/dist/integrations/prisma/index.js +69 -0
- package/package.json +1 -1
- package/dist/core/SOTS.d.ts +0 -39
- package/dist/core/SOTS.js +0 -158
package/dist/core/trackApi.js
CHANGED
|
@@ -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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 (
|
|
51
|
-
headers['x-tellann-run-id'] =
|
|
52
|
-
if (
|
|
53
|
-
headers['x-tellann-trace-id'] =
|
|
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.
|
|
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
|
+
};
|