Skip to content

Commit 95c39a9

Browse files
committed
feat: add --logfile and --with-categories flags to event send
Parses a log file into breadcrumbs attached to the event, matching the old sentry-cli send-event --logfile behavior: - Reads file line by line - Optionally parses 'CATEGORY: message' prefixes (--with-categories) - Uses file mtime as breadcrumb timestamp - Caps at 100 breadcrumbs (keeps the last 100 lines) Used by the self-hosted installer's error reporting: sentry-cli send-event --logfile $INSTALL_LOG ... 4 new tests: basic logfile, with-categories parsing, nonexistent file error, 100-breadcrumb cap with correct last-N behavior.
1 parent 562717a commit 95c39a9

4 files changed

Lines changed: 227 additions & 54 deletions

File tree

plugins/sentry-cli/skills/sentry-cli/references/event.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ Send a Sentry event
106106
- `-f, --fingerprint <value>... - Custom fingerprint part (repeat for multiple)`
107107
- `--timestamp <value> - Event timestamp (Unix epoch, ISO 8601, or RFC 2822)`
108108
- `--no-environ - Do not include environment variables in the event`
109+
- `--logfile <value> - Path to a log file — last 100 lines are attached as breadcrumbs`
110+
- `--with-categories - Parse 'CATEGORY: message' prefixes from logfile breadcrumbs`
109111
- `--raw - Send file contents as-is without parsing`
110112

111113
**Examples:**

src/commands/event/send.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ built entirely from the file contents.
126126
| \`-e\` / \`--extra\` | Extra data as KEY:VALUE |
127127
| \`-u\` / \`--user\` | User info as KEY:VALUE (id, email, username, ip_address) |
128128
| \`-f\` / \`--fingerprint\` | Custom fingerprint parts (repeat) |
129+
| \`--logfile\` | Attach last 100 log lines as breadcrumbs |
130+
| \`--with-categories\` | Parse 'CATEGORY: message' from logfile lines |
129131
`,
130132
},
131133
auth: "dsn",
@@ -234,6 +236,19 @@ built entirely from the file contents.
234236
default: false,
235237
optional: true,
236238
},
239+
logfile: {
240+
kind: "parsed",
241+
parse: String,
242+
brief:
243+
"Path to a log file — last 100 lines are attached as breadcrumbs",
244+
optional: true,
245+
},
246+
"with-categories": {
247+
kind: "boolean",
248+
brief: "Parse 'CATEGORY: message' prefixes from logfile breadcrumbs",
249+
default: false,
250+
optional: true,
251+
},
237252
raw: {
238253
kind: "boolean",
239254
brief: "Send file contents as-is without parsing",
@@ -298,7 +313,7 @@ built entirely from the file contents.
298313
"sentry event send -m 'My message'"
299314
);
300315
}
301-
const event = buildEventFromFlags(flags);
316+
const event = await buildEventFromFlags(flags);
302317
let body: string | Uint8Array;
303318
try {
304319
const envelope = createEventEnvelope(event, dsnComponents);

src/lib/envelope/event-builder.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
* environment variables optionally included as `extra.environ`.
88
*/
99

10-
import type { Event, SeverityLevel, User } from "@sentry/core";
10+
import { readFile, stat } from "node:fs/promises";
11+
import type { Breadcrumb, Event, SeverityLevel, User } from "@sentry/core";
1112
import { uuid4 } from "@sentry/core";
1213
import { ValidationError } from "../errors.js";
1314

@@ -25,6 +26,8 @@ export type SendEventFlags = {
2526
user?: string[];
2627
fingerprint?: string[];
2728
timestamp?: string;
29+
logfile?: string;
30+
"with-categories"?: boolean;
2831
"no-environ"?: boolean;
2932
};
3033

@@ -106,13 +109,76 @@ function parseTimestamp(ts: string | undefined): number | undefined {
106109
);
107110
}
108111

112+
/** Maximum number of breadcrumbs to attach from a logfile. */
113+
const MAX_BREADCRUMBS = 100;
114+
115+
/** Regex to split a log line into `CATEGORY: message` when --with-categories is set. */
116+
const CATEGORY_RE = /^([^:]+):\s*(.*)$/;
117+
118+
/**
119+
* Parse a logfile into an array of breadcrumbs.
120+
*
121+
* Reads the file line by line, optionally parsing `CATEGORY: message`
122+
* prefixes. Uses the file's mtime as the breadcrumb timestamp (matching
123+
* the old sentry-cli behaviour). Keeps the last {@link MAX_BREADCRUMBS}
124+
* entries.
125+
*/
126+
export async function parseBreadcrumbsFromLogfile(
127+
logfilePath: string,
128+
withCategories: boolean
129+
): Promise<Breadcrumb[]> {
130+
let content: string;
131+
let mtimeSeconds: number;
132+
try {
133+
content = await readFile(logfilePath, "utf-8");
134+
const fileStat = await stat(logfilePath);
135+
mtimeSeconds = fileStat.mtimeMs / 1000;
136+
} catch (err) {
137+
const code = (err as NodeJS.ErrnoException).code;
138+
if (code === "ENOENT") {
139+
throw new ValidationError(`Logfile not found: ${logfilePath}`, "logfile");
140+
}
141+
throw new ValidationError(
142+
`Cannot read logfile ${logfilePath}: ${(err as Error).message}`,
143+
"logfile"
144+
);
145+
}
146+
147+
const lines = content.split("\n").filter((l) => l.length > 0);
148+
const breadcrumbs: Breadcrumb[] = lines.map((line) => {
149+
if (withCategories) {
150+
const match = CATEGORY_RE.exec(line);
151+
if (match?.[1] && match[2]) {
152+
return {
153+
timestamp: mtimeSeconds,
154+
category: match[1].trim(),
155+
message: match[2].trim(),
156+
};
157+
}
158+
}
159+
return {
160+
timestamp: mtimeSeconds,
161+
category: "log",
162+
message: line,
163+
};
164+
});
165+
166+
// Keep only the last MAX_BREADCRUMBS entries
167+
if (breadcrumbs.length > MAX_BREADCRUMBS) {
168+
return breadcrumbs.slice(-MAX_BREADCRUMBS);
169+
}
170+
return breadcrumbs;
171+
}
172+
109173
/**
110174
* Build a Sentry Event from CLI flag values.
111175
*
112176
* The returned object is ready to be wrapped in an EventEnvelope and
113177
* serialized for posting to the ingest endpoint.
114178
*/
115-
export function buildEventFromFlags(flags: SendEventFlags): Event {
179+
export async function buildEventFromFlags(
180+
flags: SendEventFlags
181+
): Promise<Event> {
116182
const tags = parseKeyValuePairs(flags.tag);
117183
// environ goes first so explicit --extra environ:val overrides it
118184
const extra: Record<string, unknown> = {
@@ -141,5 +207,11 @@ export function buildEventFromFlags(flags: SendEventFlags): Event {
141207
extra: Object.keys(extra).length > 0 ? extra : undefined,
142208
user: flags.user?.length ? parseUserFields(flags.user) : undefined,
143209
fingerprint: flags.fingerprint,
210+
breadcrumbs: flags.logfile
211+
? await parseBreadcrumbsFromLogfile(
212+
flags.logfile,
213+
flags["with-categories"] ?? false
214+
)
215+
: undefined,
144216
};
145217
}

0 commit comments

Comments
 (0)