@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/TELLANN.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/TELLANN.js
CHANGED
|
@@ -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 (
|
|
51
|
-
headers['x-tellann-run-id'] =
|
|
52
|
-
if (
|
|
53
|
-
headers['x-tellann-trace-id'] =
|
|
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
|
+
}
|
package/dist/core/trackApi.d.ts
CHANGED
|
@@ -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>;
|