@autter/otlp-ingester 1.6.1 → 1.7.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/README.md +14 -2
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +15 -3
- package/dist/compat-manifest.json +16 -0
- package/dist/config.d.ts +23 -2
- package/dist/config.js +33 -8
- package/dist/normalize-browser.d.ts +14 -14
- package/dist/normalize-otlp.d.ts +6 -0
- package/dist/normalize-otlp.js +131 -84
- package/dist/server.d.ts +7 -0
- package/dist/server.js +44 -10
- package/dist/source-maps-lookup.d.ts +6 -3
- package/dist/source-maps-lookup.js +35 -13
- package/dist/source-maps.d.ts +6 -2
- package/dist/source-maps.js +12 -4
- package/dist/trace-dedupe.d.ts +98 -0
- package/dist/trace-dedupe.js +385 -0
- package/dist/types.d.ts +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,16 @@ works with its default exporter settings.
|
|
|
31
31
|
| --- | --- | --- | --- |
|
|
32
32
|
| `server` (default) | `autter_rt_…` (secret) | all endpoints | 300 req/min |
|
|
33
33
|
| `client` | `autter_rtc_…` (publishable, safe in frontend bundles) | `/v1/browser` only | origin allow-list, 120 req/min |
|
|
34
|
+
| `upload` (1.7.0+) | `autter_rtu_…` (secret, for CI) | `/v1/sourcemaps` and `/v1/sourcemaps/check` only | 300 req/min |
|
|
35
|
+
|
|
36
|
+
An upload key gets `403 {"error":"upload_key_not_allowed","detail":"autter_rtu_ keys can only upload source maps …"}`
|
|
37
|
+
on every other route, so a CI secret that leaks can upload source maps for
|
|
38
|
+
one repository and nothing else. A static entry without `scope` is an upload
|
|
39
|
+
key when its key starts with `autter_rtu_`, else a server key; an entry (or a
|
|
40
|
+
validator answer) with any other `scope` is ignored rather than treated as a
|
|
41
|
+
server key. Ingesters before 1.7.0 treat every scope but `client` as
|
|
42
|
+
`server`, so a validator must not answer `"scope": "upload"` to them: from
|
|
43
|
+
1.7.0 every validator request carries `x-autter-ingester-version`.
|
|
34
44
|
|
|
35
45
|
`/v1/browser` answers CORS preflights permissively; real enforcement (key +
|
|
36
46
|
origin allow-list) happens on the POST. Cross-origin browsers send
|
|
@@ -41,12 +51,14 @@ route parses as JSON.
|
|
|
41
51
|
AUTTER_INGEST_KEYS='[
|
|
42
52
|
{"key":"autter_rt_…","orgId":"org1","repositoryId":"repo1"},
|
|
43
53
|
{"key":"autter_rtc_…","orgId":"org1","repositoryId":"repo1",
|
|
44
|
-
"scope":"client","allowedOrigins":["https://app.example.com"]}
|
|
54
|
+
"scope":"client","allowedOrigins":["https://app.example.com"]},
|
|
55
|
+
{"key":"autter_rtu_…","orgId":"org1","repositoryId":"repo1","scope":"upload"}
|
|
45
56
|
]'
|
|
46
57
|
```
|
|
47
58
|
|
|
48
59
|
The validator webhook may return the same extra fields:
|
|
49
|
-
`{ orgId, repositoryId, scope?, allowedOrigins? }
|
|
60
|
+
`{ orgId, repositoryId, scope?, allowedOrigins? }`, with `scope` one of
|
|
61
|
+
`server`, `client`, `upload`.
|
|
50
62
|
|
|
51
63
|
### Version compatibility
|
|
52
64
|
|
package/dist/auth.d.ts
CHANGED
package/dist/auth.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { INGESTER_VERSION_HEADER } from "./compat.js";
|
|
2
|
+
import { defaultScopeFor, isIngestKeyScope } from "./config.js";
|
|
3
|
+
import { ingesterVersion } from "./sdk-versions.js";
|
|
1
4
|
const VALIDATOR_CACHE_TTL_MS = 60_000;
|
|
2
5
|
export class KeyResolver {
|
|
3
6
|
config;
|
|
@@ -6,10 +9,13 @@ export class KeyResolver {
|
|
|
6
9
|
constructor(config) {
|
|
7
10
|
this.config = config;
|
|
8
11
|
for (const entry of config.ingestKeys) {
|
|
12
|
+
const scope = entry.scope ?? defaultScopeFor(entry.key);
|
|
13
|
+
if (!isIngestKeyScope(scope))
|
|
14
|
+
continue;
|
|
9
15
|
this.staticKeys.set(entry.key, {
|
|
10
16
|
orgId: entry.orgId,
|
|
11
17
|
repositoryId: entry.repositoryId,
|
|
12
|
-
scope
|
|
18
|
+
scope,
|
|
13
19
|
allowedOrigins: entry.allowedOrigins ?? [],
|
|
14
20
|
});
|
|
15
21
|
}
|
|
@@ -44,6 +50,11 @@ export class KeyResolver {
|
|
|
44
50
|
method: "POST",
|
|
45
51
|
headers: {
|
|
46
52
|
"content-type": "application/json",
|
|
53
|
+
// Lets the validator tell which scopes this ingester enforces:
|
|
54
|
+
// before 1.7.0 any scope but "client" meant a server key, so an
|
|
55
|
+
// upload key must not be answered as "upload" to an ingester
|
|
56
|
+
// that doesn't send this header (or sends an older version).
|
|
57
|
+
[INGESTER_VERSION_HEADER]: ingesterVersion(),
|
|
47
58
|
...(this.config.keyValidatorToken
|
|
48
59
|
? { authorization: `Bearer ${this.config.keyValidatorToken}` }
|
|
49
60
|
: {}),
|
|
@@ -53,11 +64,12 @@ export class KeyResolver {
|
|
|
53
64
|
});
|
|
54
65
|
if (response.ok) {
|
|
55
66
|
const body = (await response.json());
|
|
56
|
-
|
|
67
|
+
const scope = body.scope === undefined || body.scope === null ? defaultScopeFor(key) : body.scope;
|
|
68
|
+
if (body.orgId && body.repositoryId && isIngestKeyScope(scope)) {
|
|
57
69
|
ctx = {
|
|
58
70
|
orgId: body.orgId,
|
|
59
71
|
repositoryId: body.repositoryId,
|
|
60
|
-
scope
|
|
72
|
+
scope,
|
|
61
73
|
allowedOrigins: body.allowedOrigins ?? [],
|
|
62
74
|
};
|
|
63
75
|
}
|
|
@@ -118,6 +118,22 @@
|
|
|
118
118
|
"ingester": "1.6.0",
|
|
119
119
|
"migrations": [],
|
|
120
120
|
"sdks": { "@autter/runtime-browser": "1.6.0", "@autter/runtime-next": "1.6.0", "@autter/runtime-node": "1.6.0" }
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "sourcemap_upload_keys",
|
|
124
|
+
"title": "Upload-only keys for source maps (autter_rtu_)",
|
|
125
|
+
"ingester": "1.7.0",
|
|
126
|
+
"migrations": [],
|
|
127
|
+
"route": "/v1/sourcemaps",
|
|
128
|
+
"sdks": {}
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "source_map_verify",
|
|
132
|
+
"title": "Source map check by debug id (autter-runtime sourcemaps verify)",
|
|
133
|
+
"ingester": "1.7.0",
|
|
134
|
+
"migrations": ["0017-source-maps-debug-id"],
|
|
135
|
+
"route": "/v1/sourcemaps/check",
|
|
136
|
+
"sdks": {}
|
|
121
137
|
}
|
|
122
138
|
]
|
|
123
139
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
+
import type { IngestKeyScope } from "./types.js";
|
|
2
|
+
export declare const INGEST_KEY_SCOPES: readonly IngestKeyScope[];
|
|
3
|
+
/** Prefix of upload-only keys (`autter_rtu_…`): source map uploads from CI. */
|
|
4
|
+
export declare const UPLOAD_KEY_PREFIX = "autter_rtu_";
|
|
5
|
+
export declare function isIngestKeyScope(value: unknown): value is IngestKeyScope;
|
|
6
|
+
/**
|
|
7
|
+
* The scope of a key whose static entry or validator answer names none:
|
|
8
|
+
* "upload" for an `autter_rtu_…` key (its prefix promises no more than
|
|
9
|
+
* that), else "server" as before 1.7.0.
|
|
10
|
+
*/
|
|
11
|
+
export declare function defaultScopeFor(key: string): IngestKeyScope;
|
|
1
12
|
export interface StaticIngestKey {
|
|
2
13
|
key: string;
|
|
3
14
|
orgId: string;
|
|
4
15
|
repositoryId: string;
|
|
5
|
-
/**
|
|
6
|
-
|
|
16
|
+
/**
|
|
17
|
+
* "server" = secret backend key, every route; "client" = publishable
|
|
18
|
+
* browser key, `/v1/browser` only; "upload" = CI key, `/v1/sourcemaps`
|
|
19
|
+
* and `/v1/sourcemaps/check` only. Default: "upload" for an
|
|
20
|
+
* `autter_rtu_…` key, else "server". An entry with any other value is
|
|
21
|
+
* ignored (never treated as a server key).
|
|
22
|
+
*/
|
|
23
|
+
scope?: IngestKeyScope;
|
|
7
24
|
/** client keys: exact origins allowed to send (e.g. https://app.example.com). */
|
|
8
25
|
allowedOrigins?: string[];
|
|
9
26
|
}
|
|
@@ -61,4 +78,8 @@ export interface IngesterConfig {
|
|
|
61
78
|
*/
|
|
62
79
|
sourceMapCacheMb?: number;
|
|
63
80
|
}
|
|
81
|
+
/** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
|
|
82
|
+
* repositoryId, or with an unknown scope, are dropped (a typo in `scope`
|
|
83
|
+
* must not turn a restricted key into a server key). */
|
|
84
|
+
export declare function parseIngestKeys(raw: string | undefined): StaticIngestKey[];
|
|
64
85
|
export declare function loadConfig(): IngesterConfig;
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
export const INGEST_KEY_SCOPES = ["server", "client", "upload"];
|
|
2
|
+
/** Prefix of upload-only keys (`autter_rtu_…`): source map uploads from CI. */
|
|
3
|
+
export const UPLOAD_KEY_PREFIX = "autter_rtu_";
|
|
4
|
+
export function isIngestKeyScope(value) {
|
|
5
|
+
return typeof value === "string" && INGEST_KEY_SCOPES.includes(value);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The scope of a key whose static entry or validator answer names none:
|
|
9
|
+
* "upload" for an `autter_rtu_…` key (its prefix promises no more than
|
|
10
|
+
* that), else "server" as before 1.7.0.
|
|
11
|
+
*/
|
|
12
|
+
export function defaultScopeFor(key) {
|
|
13
|
+
return key.startsWith(UPLOAD_KEY_PREFIX) ? "upload" : "server";
|
|
14
|
+
}
|
|
1
15
|
function intEnv(name, fallback) {
|
|
2
16
|
const raw = process.env[name];
|
|
3
17
|
if (!raw)
|
|
@@ -19,22 +33,33 @@ function patternListEnv(name) {
|
|
|
19
33
|
new RegExp(source);
|
|
20
34
|
return parsed;
|
|
21
35
|
}
|
|
22
|
-
|
|
36
|
+
/** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
|
|
37
|
+
* repositoryId, or with an unknown scope, are dropped (a typo in `scope`
|
|
38
|
+
* must not turn a restricted key into a server key). */
|
|
39
|
+
export function parseIngestKeys(raw) {
|
|
23
40
|
if (!raw)
|
|
24
41
|
return [];
|
|
42
|
+
let parsed;
|
|
25
43
|
try {
|
|
26
|
-
|
|
27
|
-
if (!Array.isArray(parsed))
|
|
28
|
-
return [];
|
|
29
|
-
return parsed.filter((entry) => entry &&
|
|
30
|
-
typeof entry.key === "string" &&
|
|
31
|
-
typeof entry.orgId === "string" &&
|
|
32
|
-
typeof entry.repositoryId === "string");
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
33
45
|
}
|
|
34
46
|
catch {
|
|
35
47
|
console.error("AUTTER_INGEST_KEYS is not valid JSON — ignoring");
|
|
36
48
|
return [];
|
|
37
49
|
}
|
|
50
|
+
if (!Array.isArray(parsed))
|
|
51
|
+
return [];
|
|
52
|
+
const keys = [];
|
|
53
|
+
parsed.forEach((entry, index) => {
|
|
54
|
+
if (!entry || typeof entry.key !== "string" || typeof entry.orgId !== "string" || typeof entry.repositoryId !== "string")
|
|
55
|
+
return;
|
|
56
|
+
if (entry.scope !== undefined && !isIngestKeyScope(entry.scope)) {
|
|
57
|
+
console.error(`AUTTER_INGEST_KEYS[${index}] has scope ${JSON.stringify(entry.scope)}; expected one of ${INGEST_KEY_SCOPES.join(", ")} — ignoring this key`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
keys.push({ ...entry, scope: entry.scope ?? defaultScopeFor(entry.key) });
|
|
61
|
+
});
|
|
62
|
+
return keys;
|
|
38
63
|
}
|
|
39
64
|
export function loadConfig() {
|
|
40
65
|
const config = {
|
|
@@ -52,15 +52,15 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
52
52
|
*/
|
|
53
53
|
traceId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
54
54
|
}, "strip", z.ZodTypeAny, {
|
|
55
|
+
type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
|
|
55
56
|
message: string;
|
|
56
|
-
type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
|
|
57
57
|
timestamp: string;
|
|
58
|
+
breadcrumbs?: unknown[] | undefined;
|
|
59
|
+
route?: string | undefined;
|
|
58
60
|
severity?: "info" | "warning" | "error" | "fatal" | undefined;
|
|
59
61
|
errorType?: string | undefined;
|
|
60
62
|
stack?: string | undefined;
|
|
61
|
-
route?: string | undefined;
|
|
62
63
|
traceId?: string | undefined;
|
|
63
|
-
breadcrumbs?: unknown[] | undefined;
|
|
64
64
|
debugIds?: Record<string, unknown> | undefined;
|
|
65
65
|
durationMs?: number | undefined;
|
|
66
66
|
filename?: string | undefined;
|
|
@@ -71,15 +71,15 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
71
71
|
context?: Record<string, unknown> | undefined;
|
|
72
72
|
fingerprint?: string | string[] | undefined;
|
|
73
73
|
}, {
|
|
74
|
-
type: "
|
|
74
|
+
type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
|
|
75
75
|
timestamp: string;
|
|
76
|
+
breadcrumbs?: unknown;
|
|
77
|
+
route?: string | undefined;
|
|
76
78
|
message?: string | undefined;
|
|
77
79
|
severity?: "info" | "warning" | "error" | "fatal" | undefined;
|
|
78
80
|
errorType?: string | undefined;
|
|
79
81
|
stack?: string | undefined;
|
|
80
|
-
route?: string | undefined;
|
|
81
82
|
traceId?: unknown;
|
|
82
|
-
breadcrumbs?: unknown;
|
|
83
83
|
debugIds?: unknown;
|
|
84
84
|
durationMs?: number | undefined;
|
|
85
85
|
filename?: string | undefined;
|
|
@@ -91,19 +91,19 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
91
91
|
fingerprint?: unknown;
|
|
92
92
|
}>, "many">;
|
|
93
93
|
}, "strip", z.ZodTypeAny, {
|
|
94
|
+
version: 1;
|
|
94
95
|
service: string;
|
|
95
96
|
environment: string;
|
|
96
|
-
version: 1;
|
|
97
97
|
events: {
|
|
98
|
+
type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
|
|
98
99
|
message: string;
|
|
99
|
-
type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
|
|
100
100
|
timestamp: string;
|
|
101
|
+
breadcrumbs?: unknown[] | undefined;
|
|
102
|
+
route?: string | undefined;
|
|
101
103
|
severity?: "info" | "warning" | "error" | "fatal" | undefined;
|
|
102
104
|
errorType?: string | undefined;
|
|
103
105
|
stack?: string | undefined;
|
|
104
|
-
route?: string | undefined;
|
|
105
106
|
traceId?: string | undefined;
|
|
106
|
-
breadcrumbs?: unknown[] | undefined;
|
|
107
107
|
debugIds?: Record<string, unknown> | undefined;
|
|
108
108
|
durationMs?: number | undefined;
|
|
109
109
|
filename?: string | undefined;
|
|
@@ -118,19 +118,19 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
118
118
|
sessionId?: string | undefined;
|
|
119
119
|
sdk?: string | undefined;
|
|
120
120
|
}, {
|
|
121
|
+
version: 1;
|
|
121
122
|
service: string;
|
|
122
123
|
environment: string;
|
|
123
|
-
version: 1;
|
|
124
124
|
events: {
|
|
125
|
-
type: "
|
|
125
|
+
type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
|
|
126
126
|
timestamp: string;
|
|
127
|
+
breadcrumbs?: unknown;
|
|
128
|
+
route?: string | undefined;
|
|
127
129
|
message?: string | undefined;
|
|
128
130
|
severity?: "info" | "warning" | "error" | "fatal" | undefined;
|
|
129
131
|
errorType?: string | undefined;
|
|
130
132
|
stack?: string | undefined;
|
|
131
|
-
route?: string | undefined;
|
|
132
133
|
traceId?: unknown;
|
|
133
|
-
breadcrumbs?: unknown;
|
|
134
134
|
debugIds?: unknown;
|
|
135
135
|
durationMs?: number | undefined;
|
|
136
136
|
filename?: string | undefined;
|
package/dist/normalize-otlp.d.ts
CHANGED
|
@@ -78,7 +78,13 @@ export interface OtlpMetricsRequest {
|
|
|
78
78
|
}>;
|
|
79
79
|
}
|
|
80
80
|
export interface NormalizedTraces {
|
|
81
|
+
/** One per thrown error: duplicate recordings across a trace's spans are
|
|
82
|
+
* merged and redundant status-only occurrences dropped (trace-dedupe.ts). */
|
|
81
83
|
occurrences: RuntimeOccurrenceInput[];
|
|
84
|
+
/** Each occurrence's position among every signal the batch recorded,
|
|
85
|
+
* before deduplication: the batch index its deterministic id is derived
|
|
86
|
+
* from, so a merge or a drop never shifts another occurrence's id. */
|
|
87
|
+
occurrenceBatchIndexes: number[];
|
|
82
88
|
spans: RuntimeSpanRow[];
|
|
83
89
|
metricPoints: RuntimeMetricPoint[];
|
|
84
90
|
llmCalls: RuntimeLlmCall[];
|
package/dist/normalize-otlp.js
CHANGED
|
@@ -5,6 +5,7 @@ import { scrubText } from "./redact.js";
|
|
|
5
5
|
import { normalizeRoute } from "./fingerprint.js";
|
|
6
6
|
import { extractLlmCall } from "./llm.js";
|
|
7
7
|
import { BREADCRUMBS_ATTRIBUTE, breadcrumbsField } from "./breadcrumbs.js";
|
|
8
|
+
import { dedupeTraceSignals, hasAutterKey, hasNextContext } from "./trace-dedupe.js";
|
|
8
9
|
import { asSeverity, } from "./types.js";
|
|
9
10
|
const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
|
|
10
11
|
function attrMap(attributes) {
|
|
@@ -100,8 +101,10 @@ function severityOf(eventAttrs, spanAttrs) {
|
|
|
100
101
|
}
|
|
101
102
|
const MAX_SPANS_PER_REQUEST = 5000;
|
|
102
103
|
export function normalizeTraces(request) {
|
|
103
|
-
const
|
|
104
|
-
|
|
104
|
+
const signals = [];
|
|
105
|
+
// Per signal: whether a server span's request rollup already counts it,
|
|
106
|
+
// and the route its own rollup keys on (before HTTP inheritance).
|
|
107
|
+
const signalRollups = [];
|
|
105
108
|
const spans = [];
|
|
106
109
|
const llmCalls = [];
|
|
107
110
|
const rollups = new Map();
|
|
@@ -143,39 +146,56 @@ export function normalizeTraces(request) {
|
|
|
143
146
|
},
|
|
144
147
|
startedAt,
|
|
145
148
|
});
|
|
146
|
-
// Error
|
|
149
|
+
// Error signals: one per exception event; if the span is
|
|
147
150
|
// errored without exception events, one from the span status.
|
|
148
|
-
|
|
151
|
+
// They become occurrences after the batch-wide dedupe below.
|
|
152
|
+
const traceId = span.traceId ?? "";
|
|
153
|
+
const spanId = span.spanId ?? "";
|
|
154
|
+
const rollup = {
|
|
155
|
+
onRequestRollup: kind === "server" && attrs.get("autter.tail_retained") !== "true",
|
|
156
|
+
route: route ?? "",
|
|
157
|
+
};
|
|
158
|
+
const spanAutter = attrs.has("autter.severity");
|
|
149
159
|
const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
|
|
150
160
|
for (const event of exceptionEvents) {
|
|
151
161
|
const eventAttrs = attrMap(event.attributes);
|
|
152
162
|
const handled = eventAttrs.get("autter.handled") === "true";
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
163
|
+
const eventAutter = hasAutterKey(eventAttrs);
|
|
164
|
+
signalRollups.push(rollup);
|
|
165
|
+
signals.push({
|
|
166
|
+
traceId,
|
|
167
|
+
spanId,
|
|
168
|
+
signal: "exception",
|
|
169
|
+
autterCapture: eventAutter || spanAutter,
|
|
170
|
+
eventAutter,
|
|
171
|
+
nextContext: hasNextContext(eventAttrs, attrs),
|
|
172
|
+
handled,
|
|
173
|
+
occurrence: {
|
|
174
|
+
source: "server",
|
|
175
|
+
severity: severityOf(eventAttrs, attrs),
|
|
176
|
+
service: resource.service,
|
|
177
|
+
environment: resource.environment,
|
|
178
|
+
release: resource.release,
|
|
179
|
+
errorType: eventAttrs.get("exception.type") ?? "Error",
|
|
180
|
+
message: eventAttrs.get("exception.message") ??
|
|
181
|
+
span.status?.message ??
|
|
182
|
+
span.name ??
|
|
183
|
+
"Unknown error",
|
|
184
|
+
stack: eventAttrs.get("exception.stacktrace") ?? null,
|
|
185
|
+
route,
|
|
186
|
+
method: methodOf(attrs),
|
|
187
|
+
statusCode,
|
|
188
|
+
traceId: span.traceId ?? null,
|
|
189
|
+
sessionId: null,
|
|
190
|
+
attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes),
|
|
191
|
+
...(handled ? { "autter.handled": true, "autter.sampled": eventAttrs.get("autter.sampled") === "true" } : {}) }),
|
|
192
|
+
occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
|
|
193
|
+
// Event attributes first: one span can carry several
|
|
194
|
+
// exceptions, each with its own code.
|
|
195
|
+
...liftErrorFields(eventAttrs, attrs),
|
|
196
|
+
...breadcrumbsField(eventAttrs.get(BREADCRUMBS_ATTRIBUTE), attrs.get(BREADCRUMBS_ATTRIBUTE)),
|
|
197
|
+
...fingerprintOverrideOf(eventAttrs.get("autter.fingerprint") ?? attrs.get("autter.fingerprint")),
|
|
198
|
+
},
|
|
179
199
|
});
|
|
180
200
|
}
|
|
181
201
|
const failedOutcomes = (span.events ?? []).filter((event) => {
|
|
@@ -186,40 +206,58 @@ export function normalizeTraces(request) {
|
|
|
186
206
|
for (const event of failedOutcomes) {
|
|
187
207
|
const outcome = attrMap(event.attributes);
|
|
188
208
|
const name = (outcome.get("autter.outcome.name") ?? "operation").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 200);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
209
|
+
signalRollups.push(rollup);
|
|
210
|
+
signals.push({
|
|
211
|
+
traceId,
|
|
212
|
+
spanId,
|
|
213
|
+
signal: "outcome",
|
|
214
|
+
autterCapture: true,
|
|
215
|
+
eventAutter: true,
|
|
216
|
+
nextContext: hasNextContext(outcome, attrs),
|
|
217
|
+
handled: false,
|
|
218
|
+
occurrence: {
|
|
219
|
+
source: "server", severity: "error", service: resource.service,
|
|
220
|
+
environment: resource.environment, release: resource.release,
|
|
221
|
+
errorType: "OutcomeFailure",
|
|
222
|
+
message: `${name}: ${(outcome.get("autter.outcome.message") ?? "failed").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 1000)}`,
|
|
223
|
+
stack: String(decodeOtlpAttributes(event.attributes)["autter.outcome.stack"] ?? "") || null, route, method: methodOf(attrs), statusCode,
|
|
224
|
+
traceId: span.traceId ?? null, sessionId: null,
|
|
225
|
+
attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes) }),
|
|
226
|
+
occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
|
|
227
|
+
...liftErrorFields(outcome, attrs),
|
|
228
|
+
},
|
|
200
229
|
});
|
|
201
230
|
}
|
|
202
231
|
if (exceptionEvents.length === 0 && failedOutcomes.length === 0 && isError) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
232
|
+
signalRollups.push(rollup);
|
|
233
|
+
signals.push({
|
|
234
|
+
traceId,
|
|
235
|
+
spanId,
|
|
236
|
+
signal: "status",
|
|
237
|
+
autterCapture: spanAutter,
|
|
238
|
+
eventAutter: false,
|
|
239
|
+
nextContext: hasNextContext(attrs),
|
|
240
|
+
handled: false,
|
|
241
|
+
occurrence: {
|
|
242
|
+
source: "server",
|
|
243
|
+
severity: severityOf(new Map(), attrs),
|
|
244
|
+
service: resource.service,
|
|
245
|
+
environment: resource.environment,
|
|
246
|
+
release: resource.release,
|
|
247
|
+
errorType: statusCode !== null && statusCode >= 500 ? "HttpServerError" : "SpanError",
|
|
248
|
+
message: span.status?.message || (statusCode !== null && statusCode >= 500
|
|
249
|
+
? `${methodOf(attrs) ?? "HTTP"} ${route ?? span.name ?? "request"} returned ${statusCode}`
|
|
250
|
+
: `${span.name ?? "span"} failed`),
|
|
251
|
+
stack: null,
|
|
252
|
+
route,
|
|
253
|
+
method: methodOf(attrs),
|
|
254
|
+
statusCode,
|
|
255
|
+
traceId: span.traceId ?? null,
|
|
256
|
+
sessionId: null,
|
|
257
|
+
attributes: decodeOtlpAttributes(span.attributes),
|
|
258
|
+
occurredAt: startedAt,
|
|
259
|
+
...liftErrorFields(attrs),
|
|
260
|
+
},
|
|
223
261
|
});
|
|
224
262
|
}
|
|
225
263
|
// LLM provider calls (GenAI semconv, Vercel AI SDK telemetry,
|
|
@@ -249,7 +287,7 @@ export function normalizeTraces(request) {
|
|
|
249
287
|
// ships those at ~100% alongside a metrics pipeline that already
|
|
250
288
|
// counts every request, so folding them in would double-count
|
|
251
289
|
// erroring routes.
|
|
252
|
-
if (
|
|
290
|
+
if (rollup.onRequestRollup) {
|
|
253
291
|
addToRollup(rollups, {
|
|
254
292
|
service: resource.service,
|
|
255
293
|
environment: resource.environment,
|
|
@@ -262,33 +300,42 @@ export function normalizeTraces(request) {
|
|
|
262
300
|
sessionCount: 0,
|
|
263
301
|
});
|
|
264
302
|
}
|
|
265
|
-
else {
|
|
266
|
-
// Occurrences on non-server spans (captureException's internal
|
|
267
|
-
// error spans, workers, consumers) have no request rollup to
|
|
268
|
-
// ride — count each as one event / one error event, or a
|
|
269
|
-
// service whose errors arrive outside HTTP handlers groups
|
|
270
|
-
// issues while every event counter stays 0. Server spans are
|
|
271
|
-
// excluded: their request rollup above already represents them.
|
|
272
|
-
for (const occ of occurrences.slice(occurrencesBefore)) {
|
|
273
|
-
addToRollup(rollups, {
|
|
274
|
-
service: occ.service,
|
|
275
|
-
environment: occ.environment,
|
|
276
|
-
release: occ.release,
|
|
277
|
-
route: occ.route ?? "",
|
|
278
|
-
bucketAt: minuteBucket(occ.occurredAt),
|
|
279
|
-
requestCount: 1,
|
|
280
|
-
errorCount: occ.severity === "fatal" || occ.severity === "error" ? 1 : 0,
|
|
281
|
-
durationSumMs: 0,
|
|
282
|
-
sessionCount: 0,
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
303
|
}
|
|
287
304
|
}
|
|
288
305
|
}
|
|
289
|
-
|
|
306
|
+
// One occurrence per thrown error: recordings of one error on several
|
|
307
|
+
// spans of a trace merge, and status-only occurrences an error on their
|
|
308
|
+
// span chain explains are dropped (trace-dedupe.ts).
|
|
309
|
+
const deduped = dedupeTraceSignals(signals, spans);
|
|
310
|
+
const occurrences = deduped.occurrences;
|
|
311
|
+
// Occurrences on non-server spans (captureException's internal error
|
|
312
|
+
// spans, workers, consumers) have no request rollup to ride — count each
|
|
313
|
+
// as one event / one error event, or a service whose errors arrive
|
|
314
|
+
// outside HTTP handlers groups issues while every event counter stays 0.
|
|
315
|
+
// Server spans are excluded: their request rollup above already
|
|
316
|
+
// represents them, and so does it for a merged error any of whose
|
|
317
|
+
// recordings sits on one. Counted after the dedupe, so each thrown error
|
|
318
|
+
// is one event; a dropped status-only occurrence is none.
|
|
319
|
+
deduped.members.forEach((members, i) => {
|
|
320
|
+
if (members.some((m) => signalRollups[m].onRequestRollup))
|
|
321
|
+
return;
|
|
322
|
+
const occ = occurrences[i];
|
|
323
|
+
addToRollup(rollups, {
|
|
324
|
+
service: occ.service,
|
|
325
|
+
environment: occ.environment,
|
|
326
|
+
release: occ.release,
|
|
327
|
+
route: signalRollups[deduped.sourceIndexes[i]].route,
|
|
328
|
+
bucketAt: minuteBucket(occ.occurredAt),
|
|
329
|
+
requestCount: 1,
|
|
330
|
+
errorCount: occ.severity === "fatal" || occ.severity === "error" ? 1 : 0,
|
|
331
|
+
durationSumMs: 0,
|
|
332
|
+
sessionCount: 0,
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
inheritHttpFromParent(occurrences, deduped.sourceIndexes.map((index) => signals[index].spanId), spans);
|
|
290
336
|
return {
|
|
291
337
|
occurrences,
|
|
338
|
+
occurrenceBatchIndexes: deduped.sourceIndexes,
|
|
292
339
|
spans,
|
|
293
340
|
metricPoints: [...rollups.values()],
|
|
294
341
|
llmCalls,
|
package/dist/server.d.ts
CHANGED
|
@@ -11,4 +11,11 @@ export interface IngesterApp {
|
|
|
11
11
|
* Autter dashboard). Called after the boot schema warm-up; throttled. */
|
|
12
12
|
reportIngesterInfo: () => Promise<void>;
|
|
13
13
|
}
|
|
14
|
+
/** The only routes an upload key (`autter_rtu_…`, scope "upload") may call. */
|
|
15
|
+
export declare const UPLOAD_KEY_ROUTES: readonly ["/v1/sourcemaps", "/v1/sourcemaps/check"];
|
|
16
|
+
/** The 403 an upload key gets on every other authenticated route. */
|
|
17
|
+
export declare const UPLOAD_KEY_NOT_ALLOWED: {
|
|
18
|
+
readonly error: "upload_key_not_allowed";
|
|
19
|
+
readonly detail: string;
|
|
20
|
+
};
|
|
14
21
|
export declare function createIngesterApp(config: IngesterConfig): IngesterApp;
|