@oneuptime/common 10.5.17 → 10.5.19
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/Server/API/TelemetryAPI.ts +6 -0
- package/Server/EnvironmentConfig.ts +27 -0
- package/Server/Infrastructure/ClickhouseDatabase.ts +21 -1
- package/Server/Infrastructure/Postgres/DataSourceOptions.ts +19 -0
- package/Server/Infrastructure/PostgresDatabase.ts +27 -1
- package/Server/Infrastructure/QueueWorker.ts +14 -3
- package/Server/Infrastructure/Redis.ts +11 -0
- package/Server/Services/TelemetryAttributeService.ts +38 -2
- package/Server/Utils/Express.ts +32 -0
- package/Server/Utils/GracefulShutdown.ts +194 -0
- package/Server/Utils/Monitor/MonitorLogUtil.ts +22 -17
- package/Server/Utils/Profiling.ts +14 -6
- package/Server/Utils/Telemetry/LogExceptionExtractor.ts +289 -0
- package/Server/Utils/Telemetry/StackTraceParser.ts +423 -0
- package/Server/Utils/Telemetry.ts +15 -5
- package/Tests/Server/Services/TelemetryAttributeService.test.ts +83 -0
- package/Tests/Server/Utils/Telemetry/LogExceptionExtractor.test.ts +0 -0
- package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -1
- package/UI/Components/Dictionary/Dictionary.tsx +19 -0
- package/UI/Components/Filters/FiltersForm.tsx +1 -0
- package/UI/Components/Filters/JSONFilter.tsx +2 -0
- package/UI/Components/Filters/Types/Filter.ts +1 -0
- package/build/dist/Server/API/TelemetryAPI.js +4 -0
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +19 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/ClickhouseDatabase.js +16 -2
- package/build/dist/Server/Infrastructure/ClickhouseDatabase.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +10 -9
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
- package/build/dist/Server/Infrastructure/PostgresDatabase.js +20 -1
- package/build/dist/Server/Infrastructure/PostgresDatabase.js.map +1 -1
- package/build/dist/Server/Infrastructure/QueueWorker.js +9 -2
- package/build/dist/Server/Infrastructure/QueueWorker.js.map +1 -1
- package/build/dist/Server/Infrastructure/Redis.js +5 -0
- package/build/dist/Server/Infrastructure/Redis.js.map +1 -1
- package/build/dist/Server/Services/TelemetryAttributeService.js +23 -1
- package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
- package/build/dist/Server/Utils/Express.js +23 -0
- package/build/dist/Server/Utils/Express.js.map +1 -1
- package/build/dist/Server/Utils/GracefulShutdown.js +145 -0
- package/build/dist/Server/Utils/GracefulShutdown.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorLogUtil.js +12 -10
- package/build/dist/Server/Utils/Monitor/MonitorLogUtil.js.map +1 -1
- package/build/dist/Server/Utils/Profiling.js +8 -3
- package/build/dist/Server/Utils/Profiling.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +214 -0
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/StackTraceParser.js +365 -0
- package/build/dist/Server/Utils/Telemetry/StackTraceParser.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry.js +10 -4
- package/build/dist/Server/Utils/Telemetry.js.map +1 -1
- package/build/dist/Tests/Server/Services/TelemetryAttributeService.test.js +50 -0
- package/build/dist/Tests/Server/Services/TelemetryAttributeService.test.js.map +1 -0
- package/build/dist/Tests/Server/Utils/Telemetry/LogExceptionExtractor.test.js +0 -0
- package/build/dist/Tests/Server/Utils/Telemetry/LogExceptionExtractor.test.js.map +1 -0
- package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +7 -1
- package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/Dictionary.js +10 -0
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/Filters/FiltersForm.js +1 -1
- package/build/dist/UI/Components/Filters/FiltersForm.js.map +1 -1
- package/build/dist/UI/Components/Filters/JSONFilter.js +1 -1
- package/build/dist/UI/Components/Filters/JSONFilter.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { JSONObject, JSONValue } from "../../../Types/JSON";
|
|
2
|
+
import StackTraceParser, { ParsedStackTrace } from "./StackTraceParser";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Result of detecting an exception inside a single log record. Shaped to feed
|
|
6
|
+
* the same ExceptionInstance (ClickHouse) + TelemetryException (Postgres)
|
|
7
|
+
* sinks the trace span-event path uses, so log-derived and span-derived
|
|
8
|
+
* exceptions group under one fingerprint when identical.
|
|
9
|
+
*/
|
|
10
|
+
export interface ExtractedLogException {
|
|
11
|
+
message: string;
|
|
12
|
+
exceptionType: string;
|
|
13
|
+
stackTrace: string;
|
|
14
|
+
parsedFrames: string; // JSON.stringify(StackFrame[]) or "[]"
|
|
15
|
+
escaped: boolean | null; // Path A may carry exception.escaped; Path B => null (unknown)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LogExceptionExtractorInput {
|
|
19
|
+
body: string; // post-scrub log body
|
|
20
|
+
attributes: JSONObject; // post-scrub merged log attributes
|
|
21
|
+
severityNumber: number;
|
|
22
|
+
/**
|
|
23
|
+
* True when the log carries BOTH a traceId and a spanId — i.e. it was
|
|
24
|
+
* emitted inside an instrumented span. The span-exception path is the
|
|
25
|
+
* canonical source for those, so Path B (body scan) is suppressed to avoid
|
|
26
|
+
* double-counting (which would also inflate occuranceCount and the windowed
|
|
27
|
+
* exception monitor). Path A (explicit exception.* attributes) is NOT
|
|
28
|
+
* suppressed — those are an intentional structured exception record.
|
|
29
|
+
*/
|
|
30
|
+
hasTraceAndSpan: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* OTel log severityNumber >= 17 is ERROR (17-20) or FATAL (21-24). Path B only
|
|
35
|
+
* scans those — the overwhelming majority of logs are below this and never
|
|
36
|
+
* reach the parser.
|
|
37
|
+
*/
|
|
38
|
+
const MIN_ERROR_SEVERITY_NUMBER: number = 17;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Only the first 16 KB of a body is parsed. A clean single-record stack trace
|
|
42
|
+
* fits comfortably (~150 frames); the top frames are the most diagnostic and
|
|
43
|
+
* are at the front. Guards against pathological multi-megabyte error logs on
|
|
44
|
+
* the hot path.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_PARSE_BODY_LENGTH: number = 16 * 1024;
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Raw log bodies are unbounded (unlike SDK-bounded span exception.stacktrace),
|
|
50
|
+
* so clamp what we store into the ZSTD stackTrace column and the Postgres summary.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_STORED_STACK_TRACE_LENGTH: number = 64 * 1024;
|
|
53
|
+
const MAX_MESSAGE_LENGTH: number = 1024;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Single pre-compiled signature for "this body plausibly contains a stack
|
|
57
|
+
* trace". Evaluated once before the (more expensive) multi-language parser.
|
|
58
|
+
* Covers: Python traceback header, JS/Java `at file:line`, Go panic/goroutine,
|
|
59
|
+
* Python `File "...", line N`, and a typed `SomethingException`/`SomethingError`.
|
|
60
|
+
*/
|
|
61
|
+
const LOOKS_LIKE_STACK_TRACE: RegExp =
|
|
62
|
+
/(?:Traceback \(most recent call last\)|\n\s+at\s+.+:\d+|\bpanic:\s|goroutine\s+\d+\s+\[|\n\s*File\s+"[^"]+",\s+line\s+\d+|\b[A-Za-z_][\w.$]*(?:Exception|Error)\b)/;
|
|
63
|
+
|
|
64
|
+
// Header parsers for deriving exceptionType + message from a raw body.
|
|
65
|
+
const PYTHON_TRACEBACK_HEADER: RegExp = /^Traceback \(most recent call last\):/;
|
|
66
|
+
const JAVA_THREAD_PREFIX: RegExp = /^Exception in thread\s+"[^"]*"\s+(.*)$/;
|
|
67
|
+
const GO_PANIC: RegExp = /^panic:\s*(.*)$/;
|
|
68
|
+
/*
|
|
69
|
+
* Leading identifier is optional so a bare "Error: msg" / "Exception: msg"
|
|
70
|
+
* (common in Node.js) matches as well as "TypeError" / "java.lang.IOException".
|
|
71
|
+
*/
|
|
72
|
+
const TYPED_ERROR: RegExp =
|
|
73
|
+
/^((?:[A-Za-z_][\w.$]*)?(?:Error|Exception|Warning|Fault))(?::\s*([\s\S]*))?$/;
|
|
74
|
+
const QUALIFIED_TYPE: RegExp = /^([A-Za-z_][\w.$]*\.[A-Za-z_][\w.$]*):\s*(.*)$/;
|
|
75
|
+
|
|
76
|
+
export default class LogExceptionExtractor {
|
|
77
|
+
/**
|
|
78
|
+
* Detect an exception in a single log record. Returns null when none is
|
|
79
|
+
* found. Never throws — extraction must never fail log ingest.
|
|
80
|
+
*/
|
|
81
|
+
public static extractFromLogRecord(
|
|
82
|
+
input: LogExceptionExtractorInput,
|
|
83
|
+
): ExtractedLogException | null {
|
|
84
|
+
try {
|
|
85
|
+
/*
|
|
86
|
+
* Path A — explicit OTel exception.* attributes. Always on, cheapest,
|
|
87
|
+
* highest-signal (the app explicitly recorded an exception on the log).
|
|
88
|
+
*/
|
|
89
|
+
const fromAttributes: ExtractedLogException | null =
|
|
90
|
+
LogExceptionExtractor.extractFromAttributes(input.attributes);
|
|
91
|
+
if (fromAttributes) {
|
|
92
|
+
return fromAttributes;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Path B — raw body scan. Gated to keep the hot path cheap.
|
|
96
|
+
if (input.severityNumber < MIN_ERROR_SEVERITY_NUMBER) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (input.hasTraceAndSpan) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return LogExceptionExtractor.extractFromBody(input.body);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private static extractFromAttributes(
|
|
109
|
+
attributes: JSONObject,
|
|
110
|
+
): ExtractedLogException | null {
|
|
111
|
+
if (!attributes) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const stackTrace: string = asString(attributes["exception.stacktrace"]);
|
|
116
|
+
const exceptionType: string = asString(attributes["exception.type"]);
|
|
117
|
+
const message: string = asString(attributes["exception.message"]);
|
|
118
|
+
|
|
119
|
+
if (!stackTrace && !exceptionType && !message) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const clampedStack: string = clamp(
|
|
124
|
+
stackTrace,
|
|
125
|
+
MAX_STORED_STACK_TRACE_LENGTH,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
let parsedFrames: string = "[]";
|
|
129
|
+
if (clampedStack) {
|
|
130
|
+
try {
|
|
131
|
+
const parsed: ParsedStackTrace = StackTraceParser.parse(clampedStack);
|
|
132
|
+
parsedFrames = JSON.stringify(parsed.frames);
|
|
133
|
+
} catch {
|
|
134
|
+
parsedFrames = "[]";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
message: clamp(message, MAX_MESSAGE_LENGTH),
|
|
140
|
+
exceptionType: exceptionType,
|
|
141
|
+
stackTrace: clampedStack,
|
|
142
|
+
parsedFrames: parsedFrames,
|
|
143
|
+
escaped: toNullableBoolean(attributes["exception.escaped"]),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private static extractFromBody(body: string): ExtractedLogException | null {
|
|
148
|
+
if (!body) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const sliced: string =
|
|
153
|
+
body.length > MAX_PARSE_BODY_LENGTH
|
|
154
|
+
? body.slice(0, MAX_PARSE_BODY_LENGTH)
|
|
155
|
+
: body;
|
|
156
|
+
|
|
157
|
+
if (!LOOKS_LIKE_STACK_TRACE.test(sliced)) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const parsed: ParsedStackTrace = StackTraceParser.parse(sliced);
|
|
162
|
+
|
|
163
|
+
/*
|
|
164
|
+
* Require at least one parsed frame. A signature match with zero frames is
|
|
165
|
+
* usually prose that merely mentions "...Error:" / "...Exception" — not an
|
|
166
|
+
* actual stack trace. Path A is the path allowed to emit without frames.
|
|
167
|
+
*/
|
|
168
|
+
if (!parsed.frames || parsed.frames.length === 0) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const header: { exceptionType: string; message: string } =
|
|
173
|
+
LogExceptionExtractor.parseHeader(sliced);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
message: clamp(header.message, MAX_MESSAGE_LENGTH),
|
|
177
|
+
exceptionType: header.exceptionType,
|
|
178
|
+
stackTrace: clamp(sliced, MAX_STORED_STACK_TRACE_LENGTH),
|
|
179
|
+
parsedFrames: JSON.stringify(parsed.frames),
|
|
180
|
+
escaped: null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Best-effort derivation of exceptionType + message from a raw stack-trace
|
|
186
|
+
* body. A stable, clean exceptionType improves grouping and exception-monitor
|
|
187
|
+
* targeting, but a miss is harmless — the fingerprint also uses the message
|
|
188
|
+
* and the normalized stack trace.
|
|
189
|
+
*/
|
|
190
|
+
private static parseHeader(body: string): {
|
|
191
|
+
exceptionType: string;
|
|
192
|
+
message: string;
|
|
193
|
+
} {
|
|
194
|
+
const lines: Array<string> = body
|
|
195
|
+
.split("\n")
|
|
196
|
+
.map((l: string) => {
|
|
197
|
+
return l.trim();
|
|
198
|
+
})
|
|
199
|
+
.filter((l: string) => {
|
|
200
|
+
return l.length > 0;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (lines.length === 0) {
|
|
204
|
+
return { exceptionType: "", message: "" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/*
|
|
208
|
+
* Python: header is "Traceback (most recent call last):"; the "Type: message"
|
|
209
|
+
* line is at the BOTTOM of the traceback, so scan upward for it.
|
|
210
|
+
*/
|
|
211
|
+
if (PYTHON_TRACEBACK_HEADER.test(lines[0]!)) {
|
|
212
|
+
for (let i: number = lines.length - 1; i >= 0; i--) {
|
|
213
|
+
const candidate: RegExpMatchArray | null = lines[i]!.match(TYPED_ERROR);
|
|
214
|
+
if (candidate) {
|
|
215
|
+
return {
|
|
216
|
+
exceptionType: candidate[1] || "",
|
|
217
|
+
message: (candidate[2] || "").trim(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Truncated traceback with no type line — keep the last line as message.
|
|
222
|
+
return { exceptionType: "", message: lines[lines.length - 1]! };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Strip Java's "Exception in thread "name"" prefix if present.
|
|
226
|
+
let firstLine: string = lines[0]!;
|
|
227
|
+
const threadMatch: RegExpMatchArray | null =
|
|
228
|
+
firstLine.match(JAVA_THREAD_PREFIX);
|
|
229
|
+
if (threadMatch) {
|
|
230
|
+
firstLine = threadMatch[1]!.trim();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const goMatch: RegExpMatchArray | null = firstLine.match(GO_PANIC);
|
|
234
|
+
if (goMatch) {
|
|
235
|
+
return { exceptionType: "panic", message: (goMatch[1] || "").trim() };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// "TypeError: msg", "java.lang.NullPointerException: msg", "...Exception"
|
|
239
|
+
const typedMatch: RegExpMatchArray | null = firstLine.match(TYPED_ERROR);
|
|
240
|
+
if (typedMatch) {
|
|
241
|
+
return {
|
|
242
|
+
exceptionType: typedMatch[1] || "",
|
|
243
|
+
message: (typedMatch[2] || "").trim(),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Generic qualified "pkg.Sub.Type: msg"
|
|
248
|
+
const qualifiedMatch: RegExpMatchArray | null =
|
|
249
|
+
firstLine.match(QUALIFIED_TYPE);
|
|
250
|
+
if (qualifiedMatch) {
|
|
251
|
+
return {
|
|
252
|
+
exceptionType: qualifiedMatch[1] || "",
|
|
253
|
+
message: (qualifiedMatch[2] || "").trim(),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { exceptionType: "", message: firstLine };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function asString(value: JSONValue | undefined): string {
|
|
262
|
+
if (typeof value === "string") {
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function toNullableBoolean(value: JSONValue | undefined): boolean | null {
|
|
269
|
+
if (typeof value === "boolean") {
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
if (typeof value === "string") {
|
|
273
|
+
const normalized: string = value.trim().toLowerCase();
|
|
274
|
+
if (normalized === "true") {
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
if (normalized === "false") {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function clamp(value: string, max: number): string {
|
|
285
|
+
if (value && value.length > max) {
|
|
286
|
+
return value.slice(0, max);
|
|
287
|
+
}
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack trace parser that transforms raw stack trace strings into structured frames.
|
|
3
|
+
* Supports JavaScript/Node.js, Python, Java, Go, Ruby, C#/.NET, and PHP stack traces.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface StackFrame {
|
|
7
|
+
functionName: string;
|
|
8
|
+
fileName: string;
|
|
9
|
+
lineNumber: number;
|
|
10
|
+
columnNumber?: number;
|
|
11
|
+
inApp: boolean; // true if user code, false if library/framework code
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ParsedStackTrace {
|
|
15
|
+
frames: StackFrame[];
|
|
16
|
+
raw: string; // original raw stack trace string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Known library/framework path patterns that indicate non-app code
|
|
20
|
+
const LIBRARY_PATTERNS: Array<RegExp> = [
|
|
21
|
+
// Node.js internals
|
|
22
|
+
/^node:/,
|
|
23
|
+
/^internal\//,
|
|
24
|
+
/node_modules\//,
|
|
25
|
+
/^events\.js$/,
|
|
26
|
+
/^timers\.js$/,
|
|
27
|
+
/^util\.js$/,
|
|
28
|
+
/^net\.js$/,
|
|
29
|
+
/^stream\.js$/,
|
|
30
|
+
/^buffer\.js$/,
|
|
31
|
+
// Python
|
|
32
|
+
/\/site-packages\//,
|
|
33
|
+
/\/dist-packages\//,
|
|
34
|
+
/\/lib\/python\d+\.\d+\//,
|
|
35
|
+
/\/usr\/lib\//,
|
|
36
|
+
/\/usr\/local\/lib\//,
|
|
37
|
+
/\/venv\//,
|
|
38
|
+
/\/\.venv\//,
|
|
39
|
+
/\/virtualenv\//,
|
|
40
|
+
// Java
|
|
41
|
+
/^java\./,
|
|
42
|
+
/^javax\./,
|
|
43
|
+
/^sun\./,
|
|
44
|
+
/^com\.sun\./,
|
|
45
|
+
/^org\.springframework\./,
|
|
46
|
+
/^org\.apache\./,
|
|
47
|
+
/^org\.hibernate\./,
|
|
48
|
+
/^org\.eclipse\./,
|
|
49
|
+
/^io\.netty\./,
|
|
50
|
+
/^com\.google\./,
|
|
51
|
+
/^org\.junit\./,
|
|
52
|
+
// Go
|
|
53
|
+
/^runtime\//,
|
|
54
|
+
/^net\/http\//,
|
|
55
|
+
/^testing\//,
|
|
56
|
+
/\/vendor\//,
|
|
57
|
+
/\/pkg\/mod\//,
|
|
58
|
+
// Ruby
|
|
59
|
+
/\/gems\//,
|
|
60
|
+
/\/rubygems\//,
|
|
61
|
+
/\/ruby\/\d+\.\d+\.\d+\//,
|
|
62
|
+
// C#/.NET
|
|
63
|
+
/^System\./,
|
|
64
|
+
/^Microsoft\./,
|
|
65
|
+
/^Newtonsoft\./,
|
|
66
|
+
// PHP
|
|
67
|
+
/\/vendor\//,
|
|
68
|
+
/^phar:\/\//,
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export default class StackTraceParser {
|
|
72
|
+
/**
|
|
73
|
+
* Parse a raw stack trace string into structured frames.
|
|
74
|
+
* Auto-detects the language and applies the appropriate parser.
|
|
75
|
+
*/
|
|
76
|
+
public static parse(rawStackTrace: string): ParsedStackTrace {
|
|
77
|
+
if (!rawStackTrace || rawStackTrace.trim().length === 0) {
|
|
78
|
+
return { frames: [], raw: rawStackTrace || "" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const lines: string[] = rawStackTrace.split("\n").map((l: string) => {
|
|
82
|
+
return l.trim();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Try each parser and use the one that produces the most frames
|
|
86
|
+
const parsers: Array<(lines: string[]) => StackFrame[]> = [
|
|
87
|
+
StackTraceParser.parseJavaScript,
|
|
88
|
+
StackTraceParser.parsePython,
|
|
89
|
+
StackTraceParser.parseJava,
|
|
90
|
+
StackTraceParser.parseGo,
|
|
91
|
+
StackTraceParser.parseRuby,
|
|
92
|
+
StackTraceParser.parseCSharp,
|
|
93
|
+
StackTraceParser.parsePHP,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
let bestFrames: StackFrame[] = [];
|
|
97
|
+
|
|
98
|
+
for (const parser of parsers) {
|
|
99
|
+
try {
|
|
100
|
+
const frames: StackFrame[] = parser(lines);
|
|
101
|
+
if (frames.length > bestFrames.length) {
|
|
102
|
+
bestFrames = frames;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Skip failing parsers
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
frames: bestFrames,
|
|
111
|
+
raw: rawStackTrace,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Determine if a file path is application code (not library/framework).
|
|
117
|
+
*/
|
|
118
|
+
private static isAppCode(filePath: string): boolean {
|
|
119
|
+
if (!filePath) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const pattern of LIBRARY_PATTERNS) {
|
|
124
|
+
if (pattern.test(filePath)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse JavaScript/Node.js stack traces.
|
|
134
|
+
* Format: `at functionName (filePath:line:col)` or `at filePath:line:col`
|
|
135
|
+
*/
|
|
136
|
+
private static parseJavaScript(lines: string[]): StackFrame[] {
|
|
137
|
+
const frames: StackFrame[] = [];
|
|
138
|
+
|
|
139
|
+
// Pattern 1: at functionName (filePath:line:col)
|
|
140
|
+
const patternWithParens: RegExp = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/;
|
|
141
|
+
// Pattern 2: at filePath:line:col
|
|
142
|
+
const patternWithoutParens: RegExp = /^at\s+(.+?):(\d+):(\d+)$/;
|
|
143
|
+
// Pattern 3: at functionName (filePath:line)
|
|
144
|
+
const patternWithParensNoCol: RegExp = /^at\s+(.+?)\s+\((.+?):(\d+)\)$/;
|
|
145
|
+
// Pattern 4: at eval (eval at functionName (filePath:line:col))
|
|
146
|
+
const patternEval: RegExp =
|
|
147
|
+
/^at\s+eval\s+\(eval\s+at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/;
|
|
148
|
+
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
let match: RegExpMatchArray | null = null;
|
|
151
|
+
|
|
152
|
+
match = line.match(patternEval);
|
|
153
|
+
if (match) {
|
|
154
|
+
frames.push({
|
|
155
|
+
functionName: `eval at ${match[1]!}`,
|
|
156
|
+
fileName: match[2]!,
|
|
157
|
+
lineNumber: parseInt(match[3]!, 10),
|
|
158
|
+
columnNumber: parseInt(match[4]!, 10),
|
|
159
|
+
inApp: StackTraceParser.isAppCode(match[2]!),
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
match = line.match(patternWithParens);
|
|
165
|
+
if (match) {
|
|
166
|
+
frames.push({
|
|
167
|
+
functionName: match[1]!,
|
|
168
|
+
fileName: match[2]!,
|
|
169
|
+
lineNumber: parseInt(match[3]!, 10),
|
|
170
|
+
columnNumber: parseInt(match[4]!, 10),
|
|
171
|
+
inApp: StackTraceParser.isAppCode(match[2]!),
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
match = line.match(patternWithParensNoCol);
|
|
177
|
+
if (match) {
|
|
178
|
+
frames.push({
|
|
179
|
+
functionName: match[1]!,
|
|
180
|
+
fileName: match[2]!,
|
|
181
|
+
lineNumber: parseInt(match[3]!, 10),
|
|
182
|
+
inApp: StackTraceParser.isAppCode(match[2]!),
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
match = line.match(patternWithoutParens);
|
|
188
|
+
if (match) {
|
|
189
|
+
frames.push({
|
|
190
|
+
functionName: "<anonymous>",
|
|
191
|
+
fileName: match[1]!,
|
|
192
|
+
lineNumber: parseInt(match[2]!, 10),
|
|
193
|
+
columnNumber: parseInt(match[3]!, 10),
|
|
194
|
+
inApp: StackTraceParser.isAppCode(match[1]!),
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return frames;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Parse Python stack traces.
|
|
205
|
+
* Format: `File "path", line N, in function`
|
|
206
|
+
*/
|
|
207
|
+
private static parsePython(lines: string[]): StackFrame[] {
|
|
208
|
+
const frames: StackFrame[] = [];
|
|
209
|
+
const pattern: RegExp =
|
|
210
|
+
/^File\s+"(.+?)",\s+line\s+(\d+)(?:,\s+in\s+(.+))?$/;
|
|
211
|
+
|
|
212
|
+
for (const line of lines) {
|
|
213
|
+
const match: RegExpMatchArray | null = line.match(pattern);
|
|
214
|
+
if (match) {
|
|
215
|
+
frames.push({
|
|
216
|
+
functionName: match[3] || "<module>",
|
|
217
|
+
fileName: match[1]!,
|
|
218
|
+
lineNumber: parseInt(match[2]!, 10),
|
|
219
|
+
inApp: StackTraceParser.isAppCode(match[1]!),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return frames;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Parse Java stack traces.
|
|
229
|
+
* Format: `at package.Class.method(File.java:line)`
|
|
230
|
+
*/
|
|
231
|
+
private static parseJava(lines: string[]): StackFrame[] {
|
|
232
|
+
const frames: StackFrame[] = [];
|
|
233
|
+
// Pattern: at com.package.Class.method(File.java:123)
|
|
234
|
+
const pattern: RegExp = /^at\s+([\w.$]+)\(([\w.]+):(\d+)\)$/;
|
|
235
|
+
// Pattern for native methods: at com.package.Class.method(Native Method)
|
|
236
|
+
const patternNative: RegExp = /^at\s+([\w.$]+)\(Native Method\)$/;
|
|
237
|
+
// Pattern for unknown source: at com.package.Class.method(Unknown Source)
|
|
238
|
+
const patternUnknown: RegExp = /^at\s+([\w.$]+)\(Unknown Source\)$/;
|
|
239
|
+
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
let match: RegExpMatchArray | null = null;
|
|
242
|
+
|
|
243
|
+
match = line.match(pattern);
|
|
244
|
+
if (match) {
|
|
245
|
+
const fullMethod: string = match[1]!;
|
|
246
|
+
frames.push({
|
|
247
|
+
functionName: fullMethod,
|
|
248
|
+
fileName: match[2]!,
|
|
249
|
+
lineNumber: parseInt(match[3]!, 10),
|
|
250
|
+
inApp: StackTraceParser.isAppCode(fullMethod),
|
|
251
|
+
});
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
match = line.match(patternNative);
|
|
256
|
+
if (match) {
|
|
257
|
+
frames.push({
|
|
258
|
+
functionName: match[1]!,
|
|
259
|
+
fileName: "Native Method",
|
|
260
|
+
lineNumber: 0,
|
|
261
|
+
inApp: false,
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
match = line.match(patternUnknown);
|
|
267
|
+
if (match) {
|
|
268
|
+
frames.push({
|
|
269
|
+
functionName: match[1]!,
|
|
270
|
+
fileName: "Unknown Source",
|
|
271
|
+
lineNumber: 0,
|
|
272
|
+
inApp: StackTraceParser.isAppCode(match[1]!),
|
|
273
|
+
});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return frames;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Parse Go stack traces.
|
|
283
|
+
* Format: `package/file.go:line +0xNN` or `goroutine N [reason]:`
|
|
284
|
+
* Go stack traces have pairs of lines:
|
|
285
|
+
* functionName(args)
|
|
286
|
+
* /path/to/file.go:line +0xNN
|
|
287
|
+
*/
|
|
288
|
+
private static parseGo(lines: string[]): StackFrame[] {
|
|
289
|
+
const frames: StackFrame[] = [];
|
|
290
|
+
const filePattern: RegExp = /^(.+\.go):(\d+)\s*(?:\+0x[0-9a-f]+)?$/;
|
|
291
|
+
|
|
292
|
+
for (let i: number = 0; i < lines.length; i++) {
|
|
293
|
+
const line: string = lines[i]!;
|
|
294
|
+
|
|
295
|
+
// Skip goroutine headers
|
|
296
|
+
if (line.startsWith("goroutine ")) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Look for file:line pattern
|
|
301
|
+
const match: RegExpMatchArray | null = line.match(filePattern);
|
|
302
|
+
if (match) {
|
|
303
|
+
// The previous line should be the function name
|
|
304
|
+
let functionName: string = "<unknown>";
|
|
305
|
+
if (i > 0 && lines[i - 1]) {
|
|
306
|
+
// Remove arguments from function name
|
|
307
|
+
const funcLine: string = lines[i - 1]!;
|
|
308
|
+
const parenIndex: number = funcLine.indexOf("(");
|
|
309
|
+
functionName =
|
|
310
|
+
parenIndex > 0 ? funcLine.substring(0, parenIndex) : funcLine;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
frames.push({
|
|
314
|
+
functionName: functionName,
|
|
315
|
+
fileName: match[1]!,
|
|
316
|
+
lineNumber: parseInt(match[2]!, 10),
|
|
317
|
+
inApp: StackTraceParser.isAppCode(match[1]!),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return frames;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Parse Ruby stack traces.
|
|
327
|
+
* Format: `file:line:in 'method'` or `file:line:in \`method'`
|
|
328
|
+
*/
|
|
329
|
+
private static parseRuby(lines: string[]): StackFrame[] {
|
|
330
|
+
const frames: StackFrame[] = [];
|
|
331
|
+
const pattern: RegExp = /^(.+?):(\d+):in\s+[`'](.+?)'$/;
|
|
332
|
+
|
|
333
|
+
for (const line of lines) {
|
|
334
|
+
const match: RegExpMatchArray | null = line.match(pattern);
|
|
335
|
+
if (match) {
|
|
336
|
+
frames.push({
|
|
337
|
+
functionName: match[3]!,
|
|
338
|
+
fileName: match[1]!,
|
|
339
|
+
lineNumber: parseInt(match[2]!, 10),
|
|
340
|
+
inApp: StackTraceParser.isAppCode(match[1]!),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return frames;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Parse C#/.NET stack traces.
|
|
350
|
+
* Format: `at Namespace.Class.Method(params) in file:line N`
|
|
351
|
+
*/
|
|
352
|
+
private static parseCSharp(lines: string[]): StackFrame[] {
|
|
353
|
+
const frames: StackFrame[] = [];
|
|
354
|
+
// Pattern: at Namespace.Class.Method(params) in /path/to/file.cs:line 42
|
|
355
|
+
const patternWithFile: RegExp = /^at\s+(.+?)\s+in\s+(.+?):line\s+(\d+)$/;
|
|
356
|
+
// Pattern: at Namespace.Class.Method(params)
|
|
357
|
+
const patternWithoutFile: RegExp = /^at\s+([\w.<>+]+\(.*?\))$/;
|
|
358
|
+
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
let match: RegExpMatchArray | null = null;
|
|
361
|
+
|
|
362
|
+
match = line.match(patternWithFile);
|
|
363
|
+
if (match) {
|
|
364
|
+
frames.push({
|
|
365
|
+
functionName: match[1]!,
|
|
366
|
+
fileName: match[2]!,
|
|
367
|
+
lineNumber: parseInt(match[3]!, 10),
|
|
368
|
+
inApp: StackTraceParser.isAppCode(match[1]!),
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
match = line.match(patternWithoutFile);
|
|
374
|
+
if (match) {
|
|
375
|
+
frames.push({
|
|
376
|
+
functionName: match[1]!,
|
|
377
|
+
fileName: "",
|
|
378
|
+
lineNumber: 0,
|
|
379
|
+
inApp: StackTraceParser.isAppCode(match[1]!),
|
|
380
|
+
});
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return frames;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Parse PHP stack traces.
|
|
390
|
+
* Format: `#N /path/to/file.php(line): Class->method()`
|
|
391
|
+
*/
|
|
392
|
+
private static parsePHP(lines: string[]): StackFrame[] {
|
|
393
|
+
const frames: StackFrame[] = [];
|
|
394
|
+
// Pattern: #0 /path/to/file.php(42): ClassName->method()
|
|
395
|
+
const pattern: RegExp = /^#\d+\s+(.+?)\((\d+)\):\s+(.+)$/;
|
|
396
|
+
// Pattern: #0 {main}
|
|
397
|
+
const patternMain: RegExp = /^#\d+\s+\{main\}$/;
|
|
398
|
+
|
|
399
|
+
for (const line of lines) {
|
|
400
|
+
if (patternMain.test(line)) {
|
|
401
|
+
frames.push({
|
|
402
|
+
functionName: "{main}",
|
|
403
|
+
fileName: "",
|
|
404
|
+
lineNumber: 0,
|
|
405
|
+
inApp: true,
|
|
406
|
+
});
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const match: RegExpMatchArray | null = line.match(pattern);
|
|
411
|
+
if (match) {
|
|
412
|
+
frames.push({
|
|
413
|
+
functionName: match[3]!.replace(/\(\)$/, ""),
|
|
414
|
+
fileName: match[1]!,
|
|
415
|
+
lineNumber: parseInt(match[2]!, 10),
|
|
416
|
+
inApp: StackTraceParser.isAppCode(match[1]!),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return frames;
|
|
422
|
+
}
|
|
423
|
+
}
|