aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/crash
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/crash
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/crash')
-rw-r--r--vendor/libghostty-vt/src/crash/dir.zig66
-rw-r--r--vendor/libghostty-vt/src/crash/main.zig21
-rw-r--r--vendor/libghostty-vt/src/crash/sentry.zig317
-rw-r--r--vendor/libghostty-vt/src/crash/sentry_envelope.zig597
4 files changed, 1001 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/crash/dir.zig b/vendor/libghostty-vt/src/crash/dir.zig
new file mode 100644
index 00000000..e5a8f8a0
--- /dev/null
+++ b/vendor/libghostty-vt/src/crash/dir.zig
@@ -0,0 +1,66 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const internal_os = @import("../os/main.zig");
+
+/// Returns a Dir for the default directory. The Dir.path field must be
+/// freed with the given allocator.
+pub fn defaultDir(alloc: Allocator) !Dir {
+ const crash_dir = try internal_os.xdg.state(alloc, .{ .subdir = "ghostty/crash" });
+ errdefer alloc.free(crash_dir);
+ return .{ .path = crash_dir };
+}
+
+pub const Dir = struct {
+ /// The directory where crash reports are stored. This memory is owned
+ /// by the caller.
+ path: []const u8,
+
+ /// Returns an iterator over the crash reports in this directory. This
+ /// iterator must be freed with `ReportIterator.deinit`. The iterator
+ /// may have no reports.
+ pub fn iterator(self: *const Dir) !ReportIterator {
+ var dir = std.fs.openDirAbsolute(
+ self.path,
+ .{ .iterate = true },
+ ) catch return .{};
+ errdefer dir.close();
+
+ return .{
+ .dir = dir,
+ .it = dir.iterate(),
+ };
+ }
+};
+
+pub const ReportIterator = struct {
+ dir: ?std.fs.Dir = null,
+ it: std.fs.Dir.Iterator = undefined,
+
+ pub fn deinit(self: *ReportIterator) void {
+ if (self.dir) |dir| dir.close();
+ }
+
+ pub fn next(self: *ReportIterator) !?Report {
+ // If we have no dir then we failed to open the directory.
+ const dir = self.dir orelse return null;
+
+ // Get the next file entry, if any.
+ const entry = entry: while (true) {
+ const entry = try self.it.next() orelse return null;
+ if (entry.kind != .file) continue;
+ break :entry entry;
+ };
+
+ const stat = try dir.statFile(entry.name);
+ return .{
+ .name = entry.name,
+ .mtime = stat.mtime,
+ };
+ }
+};
+
+pub const Report = struct {
+ name: []const u8,
+ mtime: i128,
+};
diff --git a/vendor/libghostty-vt/src/crash/main.zig b/vendor/libghostty-vt/src/crash/main.zig
new file mode 100644
index 00000000..1ac97185
--- /dev/null
+++ b/vendor/libghostty-vt/src/crash/main.zig
@@ -0,0 +1,21 @@
+//! The crash package contains all the logic around crash handling,
+//! whether that's setting up the system to catch crashes (Sentry client),
+//! introspecting crash reports, writing crash reports to disk, etc.
+
+const dir = @import("dir.zig");
+const sentry_envelope = @import("sentry_envelope.zig");
+
+pub const sentry = @import("sentry.zig");
+pub const Envelope = sentry_envelope.Envelope;
+pub const defaultDir = dir.defaultDir;
+pub const Dir = dir.Dir;
+pub const ReportIterator = dir.ReportIterator;
+pub const Report = dir.Report;
+
+// The main init/deinit functions for global state.
+pub const init = sentry.init;
+pub const deinit = sentry.deinit;
+
+test {
+ @import("std").testing.refAllDecls(@This());
+}
diff --git a/vendor/libghostty-vt/src/crash/sentry.zig b/vendor/libghostty-vt/src/crash/sentry.zig
new file mode 100644
index 00000000..555b70fe
--- /dev/null
+++ b/vendor/libghostty-vt/src/crash/sentry.zig
@@ -0,0 +1,317 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const builtin = @import("builtin");
+const build_config = @import("../build_config.zig");
+const build_options = @import("build_options");
+const sentry = if (build_options.sentry) @import("sentry");
+const internal_os = @import("../os/main.zig");
+const crash = @import("main.zig");
+const state = &@import("../global.zig").state;
+const Surface = @import("../Surface.zig");
+
+const log = std.log.scoped(.sentry);
+
+/// The global state for the Sentry SDK. This is unavoidable since crash
+/// handling is a global process-wide thing.
+var init_thread: ?std.Thread = null;
+
+/// Thread-local state that can be set by thread main functions so that
+/// crashes have more context.
+///
+/// This is a hack over Sentry native SDK limitations. The native SDK has
+/// one global scope for all threads and no support for thread-local scopes.
+/// This means that if we want to set thread-specific data we have to do it
+/// on our own in the on crash callback.
+pub const ThreadState = struct {
+ /// Thread type, used to tag the crash
+ type: Type,
+
+ /// The surface that this thread is attached to.
+ surface: *Surface,
+
+ pub const Type = enum { main, renderer, io };
+};
+
+/// See ThreadState. This should only ever be set by the owner of the
+/// thread entry function.
+pub threadlocal var thread_state: ?ThreadState = null;
+
+/// Process-wide initialization of our Sentry client.
+///
+/// This should only be called from one thread, and deinit should be called
+/// from the same thread that calls init to avoid data races.
+///
+/// PRIVACY NOTE: I want to make it very clear that Ghostty by default does
+/// NOT send any data over the network. We use the Sentry native SDK to collect
+/// crash reports and logs, but we only store them locally (see Transport).
+/// It is up to the user to grab the logs and manually send them to us
+/// (or they own Sentry instance) if they want to.
+pub fn init(gpa: Allocator) !void {
+ if (comptime !build_options.sentry) return;
+
+ // Not supported on Windows currently, doesn't build.
+ if (comptime builtin.os.tag == .windows) return;
+
+ // const start = try std.time.Instant.now();
+ // const start_micro = std.time.microTimestamp();
+ // defer {
+ // const end = std.time.Instant.now() catch unreachable;
+ // // "[updateFrame critical time] <START us>\t<TIME_TAKEN us>"
+ // std.log.err("[sentry init time] start={}us duration={}ns", .{ start_micro, end.since(start) / std.time.ns_per_us });
+ // }
+
+ // Must only start once
+ assert(init_thread == null);
+
+ // We use a thread for initializing Sentry because initialization takes
+ // ~2k ns on my M3 Max. That's not a LOT of time but it's enough to be
+ // 90% of our pre-App startup time. Everything Sentry is doing initially
+ // is safe to do on a separate thread and fast enough that its very
+ // likely to be done before a crash occurs.
+ const thr = try std.Thread.spawn(
+ .{},
+ initThread,
+ .{gpa},
+ );
+ thr.setName("sentry-init") catch {};
+ init_thread = thr;
+}
+
+fn initThread(gpa: Allocator) !void {
+ if (comptime !build_options.sentry) return;
+
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"sentry-init".*);
+ }
+
+ var arena = std.heap.ArenaAllocator.init(gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const transport = sentry.Transport.init(&Transport.send);
+ // This will crash if the transport was never used so we avoid
+ // that for now. This probably leaks some memory but it'd be very
+ // small and a one time cost. Once this is fixed upstream we can
+ // remove this.
+ //errdefer transport.deinit();
+
+ const opts = sentry.c.sentry_options_new();
+ errdefer sentry.c.sentry_options_free(opts);
+ sentry.c.sentry_options_set_release_n(
+ opts,
+ build_config.version_string.ptr,
+ build_config.version_string.len,
+ );
+ sentry.c.sentry_options_set_transport(opts, @ptrCast(transport));
+
+ // Set our crash callback. See beforeSend for more details on what we
+ // do here and why we use this.
+ sentry.c.sentry_options_set_before_send(opts, beforeSend, null);
+
+ // Determine the Sentry cache directory.
+ const cache_dir = cache_dir: {
+ // On macOS, we prefer to use the NSCachesDirectory value to be
+ // a more idiomatic macOS application. But if XDG env vars are set
+ // we will respect them.
+ if (comptime builtin.os.tag == .macos) macos: {
+ if (std.posix.getenv("XDG_CACHE_HOME") != null) break :macos;
+ break :cache_dir try internal_os.macos.cacheDir(
+ alloc,
+ "sentry",
+ );
+ }
+
+ break :cache_dir try internal_os.xdg.cache(
+ alloc,
+ .{ .subdir = "ghostty/sentry" },
+ );
+ };
+ sentry.c.sentry_options_set_database_path_n(
+ opts,
+ cache_dir.ptr,
+ cache_dir.len,
+ );
+
+ if (comptime builtin.mode == .Debug) {
+ // Debug logging for Sentry
+ sentry.c.sentry_options_set_debug(opts, @intFromBool(true));
+ }
+
+ // Initialize
+ if (sentry.c.sentry_init(opts) != 0) return error.SentryInitFailed;
+
+ // Setup some basic tags that we always want present
+ sentry.setTag("build-mode", build_config.mode_string);
+ sentry.setTag("app-runtime", @tagName(build_config.app_runtime));
+ sentry.setTag("font-backend", @tagName(build_config.font_backend));
+ sentry.setTag("renderer", @tagName(build_config.renderer));
+
+ // Log some information about sentry
+ log.debug("sentry initialized database={s}", .{cache_dir});
+}
+
+/// Process-wide deinitialization of our Sentry client. This ensures all
+/// our data is flushed.
+pub fn deinit() void {
+ if (comptime !build_options.sentry) return;
+
+ if (comptime builtin.os.tag == .windows) return;
+
+ // If we're still initializing then wait for init to finish. This
+ // is highly unlikely since init is a very fast operation but we want
+ // to avoid the possibility.
+ const thr = init_thread orelse return;
+ thr.join();
+ _ = sentry.c.sentry_close();
+}
+
+fn beforeSend(
+ event_val: sentry.c.sentry_value_t,
+ _: ?*anyopaque,
+ _: ?*anyopaque,
+) callconv(.c) sentry.c.sentry_value_t {
+ // The native SDK at the time of writing doesn't support thread-local
+ // scopes. The full SDK has one global scope. So we use the beforeSend
+ // handler to set thread-specific data such as window size, grid size,
+ // etc. that we can use to debug crashes.
+
+ // If we don't have thread state we can't reliably determine
+ // metadata such as surface dimensions. In the future we can probably
+ // drop full app state (all surfaces, all windows, etc.).
+ const thr_state = thread_state orelse {
+ log.debug("no thread state, skipping crash metadata", .{});
+ return event_val;
+ };
+
+ // Get our event contexts. At this point Sentry has already merged
+ // all the contexts so we should have this key. If not, we create it.
+ const event: sentry.Value = .{ .value = event_val };
+ const contexts = event.get("contexts") orelse contexts: {
+ const obj = sentry.Value.initObject();
+ event.set("contexts", obj);
+ break :contexts obj;
+ };
+ const tags = event.get("tags") orelse tags: {
+ const obj = sentry.Value.initObject();
+ event.set("tags", obj);
+ break :tags obj;
+ };
+
+ // Store our thread type
+ tags.set("thread-type", sentry.Value.initString(@tagName(thr_state.type)));
+
+ // Read the surface data. This is likely unsafe because on a crash
+ // other threads can continue running. We don't have race-safe way to
+ // access this data so this might be corrupted but it's most likely fine.
+ {
+ const obj = sentry.Value.initObject();
+ errdefer obj.decref();
+ const surface = thr_state.surface;
+ const grid_size = surface.size.grid();
+ obj.set(
+ "screen-width",
+ sentry.Value.initInt32(std.math.cast(i32, surface.size.screen.width) orelse -1),
+ );
+ obj.set(
+ "screen-height",
+ sentry.Value.initInt32(std.math.cast(i32, surface.size.screen.height) orelse -1),
+ );
+ obj.set(
+ "grid-columns",
+ sentry.Value.initInt32(std.math.cast(i32, grid_size.columns) orelse -1),
+ );
+ obj.set(
+ "grid-rows",
+ sentry.Value.initInt32(std.math.cast(i32, grid_size.rows) orelse -1),
+ );
+ obj.set(
+ "cell-width",
+ sentry.Value.initInt32(std.math.cast(i32, surface.size.cell.width) orelse -1),
+ );
+ obj.set(
+ "cell-height",
+ sentry.Value.initInt32(std.math.cast(i32, surface.size.cell.height) orelse -1),
+ );
+
+ contexts.set("Dimensions", obj);
+ }
+
+ return event_val;
+}
+
+pub const Transport = struct {
+ pub fn send(envelope: *sentry.Envelope, ud: ?*anyopaque) callconv(.c) void {
+ _ = ud;
+ defer envelope.deinit();
+
+ // Call our internal impl. If it fails there is nothing we can do
+ // but log to the user.
+ sendInternal(envelope) catch |err| {
+ log.warn("failed to persist crash report err={}", .{err});
+ };
+ }
+
+ /// Implementation of send but we can use Zig errors.
+ fn sendInternal(envelope: *sentry.Envelope) !void {
+ var arena = std.heap.ArenaAllocator.init(state.alloc);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Parse into an envelope structure
+ const json = envelope.serialize();
+ defer sentry.free(@ptrCast(json.ptr));
+ var parsed: crash.Envelope = parsed: {
+ var reader: std.Io.Reader = .fixed(json);
+ break :parsed try crash.Envelope.parse(alloc, &reader);
+ };
+ defer parsed.deinit();
+
+ // If our envelope doesn't have an event then we don't do anything.
+ // To figure this out we first encode it into a string, parse it,
+ // and check if it has an event. Kind of wasteful but the best
+ // option we have at the time of writing this since the C API doesn't
+ // expose this information.
+ if (try shouldDiscard(&parsed)) {
+ log.info("sentry envelope does not contain crash, discarding", .{});
+ return;
+ }
+
+ // Generate a UUID for this envelope. The envelope DOES have an event_id
+ // header but I don't think there is any public API way to get it
+ // afaict so we generate a new UUID for the filename just so we don't
+ // conflict.
+ const uuid = sentry.UUID.init();
+
+ // Get our XDG state directory where we'll store the crash reports.
+ // This directory must exist for writing to work.
+ const dir = try crash.defaultDir(alloc);
+ try std.fs.cwd().makePath(dir.path);
+
+ // Build our final path and write to it.
+ const path = try std.fs.path.join(alloc, &.{
+ dir.path,
+ try std.fmt.allocPrint(alloc, "{s}.ghosttycrash", .{uuid.string()}),
+ });
+ const file = try std.fs.cwd().createFile(path, .{});
+ defer file.close();
+ var buf: [4096]u8 = undefined;
+ var file_writer = file.writer(&buf);
+ try file_writer.interface.writeAll(json);
+ try file_writer.end();
+
+ log.warn("crash report written to disk path={s}", .{path});
+ }
+
+ fn shouldDiscard(envelope: *const crash.Envelope) !bool {
+ // If we have an event item then we're good.
+ for (envelope.items.items) |item| {
+ if (item.itemType() == .event) return false;
+ }
+
+ return true;
+ }
+};
diff --git a/vendor/libghostty-vt/src/crash/sentry_envelope.zig b/vendor/libghostty-vt/src/crash/sentry_envelope.zig
new file mode 100644
index 00000000..08573b73
--- /dev/null
+++ b/vendor/libghostty-vt/src/crash/sentry_envelope.zig
@@ -0,0 +1,597 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+
+const log = std.log.scoped(.sentry_envelope);
+
+/// The Sentry Envelope format: https://develop.sentry.dev/sdk/envelopes/
+///
+/// The envelope is our primary crash report format since use the Sentry
+/// client. It is designed and created by Sentry but is an open format
+/// in that it is publicly documented and can be used by any system. This
+/// lets us utilize the Sentry client for crash capture but also gives us
+/// the opportunity to migrate to another system if we need to, and doesn't
+/// force any user or developer to use Sentry the SaaS if they don't want
+/// to.
+///
+/// This struct implements reading the envelope format (writing is not needed
+/// currently but can be added later). It is incomplete; I only implemented
+/// what I needed at the time.
+pub const Envelope = struct {
+ /// The arena that the envelope is allocated in. All items are welcome
+ /// to use this allocator for their data, which is freed on deinit.
+ arena: std.heap.ArenaAllocator,
+
+ /// The headers of the envelope decoded into a json ObjectMap.
+ headers: std.json.ObjectMap,
+
+ /// The items in the envelope in the order they're encoded.
+ items: std.ArrayList(Item),
+
+ /// Parse an envelope from a reader.
+ ///
+ /// The full envelope must fit in memory for this to succeed. This
+ /// will always copy the data from the reader into memory, even if the
+ /// reader is already in-memory (i.e. a FixedBufferStream). This
+ /// simplifies memory lifetimes at the expense of a copy, but envelope
+ /// parsing in our use case is not a hot path.
+ pub fn parse(
+ alloc_gpa: Allocator,
+ reader: *std.Io.Reader,
+ ) !Envelope {
+ // We use an arena allocator to read from reader. We pair this
+ // with `alloc_if_needed` when parsing json to allow the json
+ // to reference the arena-allocated memory if it can. That way both
+ // our temp and perm memory is part of the same arena. This slightly
+ // bloats our memory requirements but reduces allocations.
+ var arena = std.heap.ArenaAllocator.init(alloc_gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Parse our elements. We do this outside of the struct assignment
+ // below to avoid the issue where order matters in struct assignment.
+ const headers = try parseHeader(alloc, reader);
+ const items = try parseItems(alloc, reader);
+
+ return .{
+ .headers = headers,
+ .items = items,
+ .arena = arena,
+ };
+ }
+
+ fn parseHeader(
+ alloc: Allocator,
+ reader: *std.Io.Reader,
+ ) !std.json.ObjectMap {
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ _ = try reader.streamDelimiterLimit(
+ &buf.writer,
+ '\n',
+ .limited(1024 * 1024), // 1MB, arbitrary choice
+ );
+ _ = reader.discardDelimiterInclusive('\n') catch |err| switch (err) {
+ // It's okay if there isn't a trailing newline
+ error.EndOfStream => {},
+ else => return err,
+ };
+
+ const value = try std.json.parseFromSliceLeaky(
+ std.json.Value,
+ alloc,
+ buf.written(),
+ .{ .allocate = .alloc_if_needed },
+ );
+
+ return switch (value) {
+ .object => |map| map,
+ else => error.EnvelopeMalformedHeaders,
+ };
+ }
+
+ fn parseItems(
+ alloc: Allocator,
+ reader: *std.Io.Reader,
+ ) !std.ArrayList(Item) {
+ var items: std.ArrayList(Item) = .{};
+ errdefer items.deinit(alloc);
+ while (try parseOneItem(alloc, reader)) |item| {
+ try items.append(alloc, item);
+ }
+
+ return items;
+ }
+
+ fn parseOneItem(
+ alloc: Allocator,
+ reader: *std.Io.Reader,
+ ) !?Item {
+ // Get the next item which must start with a header.
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ _ = reader.streamDelimiterLimit(
+ &buf.writer,
+ '\n',
+ .limited(1024 * 1024), // 1MB, arbitrary choice
+ ) catch |err| switch (err) {
+ error.StreamTooLong => return null,
+ else => return err,
+ };
+ _ = reader.discardDelimiterInclusive('\n') catch |err| switch (err) {
+ // It's okay if there isn't a trailing newline
+ error.EndOfStream => {},
+ else => return err,
+ };
+
+ // Parse the header JSON
+ const headers: std.json.ObjectMap = headers: {
+ const line = std.mem.trim(u8, buf.written(), " \t");
+ if (line.len == 0) return null;
+
+ const value = try std.json.parseFromSliceLeaky(
+ std.json.Value,
+ alloc,
+ line,
+ .{ .allocate = .alloc_if_needed },
+ );
+
+ break :headers switch (value) {
+ .object => |map| map,
+ else => return error.EnvelopeItemMalformedHeaders,
+ };
+ };
+
+ // Get the event type
+ const typ: ItemType = if (headers.get("type")) |v| switch (v) {
+ .string => |str| std.meta.stringToEnum(
+ ItemType,
+ str,
+ ) orelse .unknown,
+ else => return error.EnvelopeItemTypeMissing,
+ } else return error.EnvelopeItemTypeMissing;
+
+ // Get the payload length. The length is not required. If the length
+ // is not specified then it is the next line ending in `\n`.
+ const len_: ?u64 = if (headers.get("length")) |v| switch (v) {
+ .integer => |int| std.math.cast(
+ u64,
+ int,
+ ) orelse return error.EnvelopeItemLengthMalformed,
+ else => return error.EnvelopeItemLengthMalformed,
+ } else null;
+
+ // Get the payload
+ const payload: []const u8 = if (len_) |len| payload: {
+ // The payload length is specified so read the exact length.
+ var payload: std.Io.Writer.Allocating = .init(alloc);
+ defer payload.deinit();
+
+ reader.streamExact(&payload.writer, len) catch |err| switch (err) {
+ error.EndOfStream => return error.EnvelopeItemPayloadTooShort,
+ else => return err,
+ };
+
+ // The next byte must be a newline.
+ if (reader.takeByte()) |byte| {
+ if (byte != '\n') return error.EnvelopeItemPayloadNoNewline;
+ } else |err| switch (err) {
+ error.EndOfStream => {},
+ else => return err,
+ }
+
+ break :payload try payload.toOwnedSlice();
+ } else payload: {
+ // The payload is the next line ending in `\n`. It is required.
+ var payload: std.Io.Writer.Allocating = .init(alloc);
+ _ = reader.streamDelimiterLimit(
+ &payload.writer,
+ '\n',
+ .limited(1024 * 1024), // 50MB, arbitrary choice
+ ) catch |err| switch (err) {
+ error.StreamTooLong => return error.EnvelopeItemPayloadTooShort,
+ else => |v| return v,
+ };
+ _ = reader.discardDelimiterInclusive('\n') catch |err| switch (err) {
+ // It's okay if there isn't a trailing newline
+ error.EndOfStream => {},
+ else => return err,
+ };
+ break :payload try payload.toOwnedSlice();
+ };
+
+ return .{ .encoded = .{
+ .headers = headers,
+ .type = typ,
+ .payload = payload,
+ } };
+ }
+
+ pub fn deinit(self: *Envelope) void {
+ self.arena.deinit();
+ }
+
+ /// The arena allocator associated with this envelope
+ pub fn allocator(self: *Envelope) Allocator {
+ return self.arena.allocator();
+ }
+
+ /// Serialize the envelope to the given writer.
+ ///
+ /// This will convert all decoded items to encoded items and
+ /// therefore may allocate.
+ pub fn serialize(
+ self: *Envelope,
+ writer: *std.Io.Writer,
+ ) !void {
+ // Header line first
+ try writer.print("{f}\n", .{std.json.fmt(
+ std.json.Value{ .object = self.headers },
+ json_opts,
+ )});
+
+ // Write each item
+ const alloc = self.allocator();
+ for (self.items.items, 0..) |*item, idx| {
+ if (idx > 0) try writer.writeByte('\n');
+
+ const encoded = try item.encode(alloc);
+ assert(item.* == .encoded);
+
+ try writer.print("{f}\n{s}", .{
+ std.json.fmt(
+ std.json.Value{ .object = encoded.headers },
+ json_opts,
+ ),
+ encoded.payload,
+ });
+ }
+ }
+};
+
+/// The various item types that can be in an envelope. This is a point
+/// in time snapshot of the types that are known whenever this is edited.
+/// Event types can be introduced at any time and unknown types will
+/// take the "unknown" enum value.
+///
+/// https://develop.sentry.dev/sdk/envelopes/#data-model
+pub const ItemType = enum {
+ /// Special event type for when the item type is unknown.
+ unknown,
+
+ /// Documented event types
+ event,
+ transaction,
+ attachment,
+ session,
+ sessions,
+ statsd,
+ metric_meta,
+ user_feedback,
+ client_report,
+ replay_event,
+ replay_recording,
+ profile,
+ check_in,
+};
+
+/// An item in the envelope. An item can be either in an encoded
+/// or decoded state. The encoded state lets us parse the envelope
+/// more cheaply since we can defer the full decoding of the item
+/// until we need it.
+///
+/// The decoded state is more ergonomic to work with and lets us
+/// easily build up new items and defer encoding until serialization
+/// time.
+pub const Item = union(enum) {
+ encoded: EncodedItem,
+ attachment: Attachment,
+
+ /// Convert the item to an encoded item. This modify the item
+ /// in place.
+ pub fn encode(
+ self: *Item,
+ alloc: Allocator,
+ ) !EncodedItem {
+ const result: EncodedItem = switch (self.*) {
+ .encoded => |v| return v,
+ .attachment => |*v| try v.encode(alloc),
+ };
+ self.* = .{ .encoded = result };
+ return result;
+ }
+
+ /// Returns the type of item represented here, whether
+ /// it is an encoded item or not.
+ pub fn itemType(self: Item) ItemType {
+ return switch (self) {
+ .encoded => |v| v.type,
+ .attachment => .attachment,
+ };
+ }
+
+ pub const DecodeError = Allocator.Error || error{
+ MissingRequiredField,
+ InvalidFieldType,
+ UnsupportedType,
+ };
+
+ /// Decode the item if it is encoded. This will modify itself.
+ /// If the item is already decoded this does nothing.
+ ///
+ /// The allocator argument should be an arena-style allocator,
+ /// typically the allocator associated with the Envelope.
+ ///
+ /// If the decoding fails because the item is in an invalid
+ /// state (i.e. its missing a required field) then this will
+ /// error but the encoded item will remain unmodified. This
+ /// allows the caller to handle the error without corrupting the
+ /// envelope.
+ ///
+ /// If decoding fails, the allocator may still allocate so the
+ /// allocator should be an arena-style allocator.
+ pub fn decode(self: *Item, alloc: Allocator) DecodeError!void {
+ // Get our encoded item. If we're not encoded we're done.
+ const encoded: EncodedItem = switch (self.*) {
+ .encoded => |v| v,
+ else => return,
+ };
+
+ // Decode the item.
+ self.* = switch (encoded.type) {
+ .attachment => .{ .attachment = try .decode(
+ alloc,
+ encoded,
+ ) },
+ else => return error.UnsupportedType,
+ };
+ }
+};
+
+/// An encoded item. It is "encoded" in the sense that the payload
+/// is a byte slice. The headers are "decoded" into a json ObjectMap
+/// but that's still a pretty low-level representation.
+pub const EncodedItem = struct {
+ headers: std.json.ObjectMap,
+ type: ItemType,
+ payload: []const u8,
+};
+
+/// An arbitrary file attachment.
+///
+/// https://develop.sentry.dev/sdk/envelopes/#attachment
+pub const Attachment = struct {
+ /// "filename" field is the name of the uploaded file without
+ /// a path component.
+ filename: []const u8,
+
+ /// A special "type" associated with the attachment. This
+ /// is documented on the Sentry website. In the future we should
+ /// make this an enum.
+ type: ?[]const u8 = null,
+
+ /// Additional headers for the attachment.
+ headers_extra: ObjectMapUnmanaged = .{},
+
+ /// The data for the attachment.
+ payload: []const u8,
+
+ pub fn decode(
+ alloc: Allocator,
+ item: EncodedItem,
+ ) Item.DecodeError!Attachment {
+ _ = alloc;
+
+ return .{
+ .filename = if (item.headers.get("filename")) |v| switch (v) {
+ .string => |str| str,
+ else => return error.InvalidFieldType,
+ } else return error.MissingRequiredField,
+
+ .type = if (item.headers.get("attachment_type")) |v| switch (v) {
+ .string => |str| str,
+ else => return error.InvalidFieldType,
+ } else null,
+
+ .headers_extra = item.headers.unmanaged,
+ .payload = item.payload,
+ };
+ }
+
+ pub fn encode(
+ self: *Attachment,
+ alloc: Allocator,
+ ) !EncodedItem {
+ try self.headers_extra.put(
+ alloc,
+ "filename",
+ .{ .string = self.filename },
+ );
+
+ if (self.type) |v| {
+ try self.headers_extra.put(
+ alloc,
+ "attachment_type",
+ .{ .string = v },
+ );
+ } else {
+ _ = self.headers_extra.swapRemove("attachment_type");
+ }
+
+ return .{
+ .headers = self.headers_extra.promote(alloc),
+ .type = .attachment,
+ .payload = self.payload,
+ };
+ }
+};
+
+/// Same as std.json.ObjectMap but unmanaged. This lets us store
+/// them alongside all our items without the overhead of duplicated
+/// allocators. Additional, items do not own their own memory so this
+/// makes it clear that deinit of an item will not free the memory.
+pub const ObjectMapUnmanaged = std.StringArrayHashMapUnmanaged(std.json.Value);
+
+/// The options we must use for serialization.
+const json_opts: std.json.Stringify.Options = .{
+ // This is the default but I want to be explicit because its
+ // VERY important for the correctness of the envelope. This is
+ // the only whitespace type in std.json that doesn't emit newlines.
+ // All JSON headers in the envelope must be on a single line.
+ .whitespace = .minified,
+};
+
+test "Envelope parse" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var reader: std.Io.Reader = .fixed(
+ \\{}
+ );
+ var v = try Envelope.parse(alloc, &reader);
+ defer v.deinit();
+}
+
+test "Envelope parse session" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var reader: std.Io.Reader = .fixed(
+ \\{}
+ \\{"type":"session","length":218}
+ \\{"init":true,"sid":"c148cc2f-5f9f-4231-575c-2e85504d6434","status":"abnormal","errors":0,"started":"2024-08-29T02:38:57.607016Z","duration":0.000343,"attrs":{"release":"0.1.0-HEAD+d37b7d09","environment":"production"}}
+ );
+ var v = try Envelope.parse(alloc, &reader);
+ defer v.deinit();
+
+ try testing.expectEqual(@as(usize, 1), v.items.items.len);
+ try testing.expectEqual(ItemType.session, v.items.items[0].encoded.type);
+}
+
+test "Envelope parse multiple" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var reader: std.Io.Reader = .fixed(
+ \\{}
+ \\{"type":"session","length":218}
+ \\{"init":true,"sid":"c148cc2f-5f9f-4231-575c-2e85504d6434","status":"abnormal","errors":0,"started":"2024-08-29T02:38:57.607016Z","duration":0.000343,"attrs":{"release":"0.1.0-HEAD+d37b7d09","environment":"production"}}
+ \\{"type":"attachment","length":4,"filename":"test.txt"}
+ \\ABCD
+ );
+ var v = try Envelope.parse(alloc, &reader);
+ defer v.deinit();
+
+ try testing.expectEqual(@as(usize, 2), v.items.items.len);
+ try testing.expectEqual(ItemType.session, v.items.items[0].encoded.type);
+ try testing.expectEqual(ItemType.attachment, v.items.items[1].encoded.type);
+}
+
+test "Envelope parse multiple no length" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var reader: std.Io.Reader = .fixed(
+ \\{}
+ \\{"type":"session"}
+ \\{}
+ \\{"type":"attachment","length":4,"filename":"test.txt"}
+ \\ABCD
+ );
+ var v = try Envelope.parse(alloc, &reader);
+ defer v.deinit();
+
+ try testing.expectEqual(@as(usize, 2), v.items.items.len);
+ try testing.expectEqual(ItemType.session, v.items.items[0].encoded.type);
+ try testing.expectEqual(ItemType.attachment, v.items.items[1].encoded.type);
+}
+
+test "Envelope parse end in new line" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var reader: std.Io.Reader = .fixed(
+ \\{}
+ \\{"type":"session","length":218}
+ \\{"init":true,"sid":"c148cc2f-5f9f-4231-575c-2e85504d6434","status":"abnormal","errors":0,"started":"2024-08-29T02:38:57.607016Z","duration":0.000343,"attrs":{"release":"0.1.0-HEAD+d37b7d09","environment":"production"}}
+ \\
+ );
+ var v = try Envelope.parse(alloc, &reader);
+ defer v.deinit();
+
+ try testing.expectEqual(@as(usize, 1), v.items.items.len);
+ try testing.expectEqual(ItemType.session, v.items.items[0].encoded.type);
+}
+
+test "Envelope parse attachment" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var reader: std.Io.Reader = .fixed(
+ \\{}
+ \\{"type":"attachment","length":4,"filename":"test.txt"}
+ \\ABCD
+ );
+ var v = try Envelope.parse(alloc, &reader);
+ defer v.deinit();
+
+ try testing.expectEqual(@as(usize, 1), v.items.items.len);
+
+ var item = &v.items.items[0];
+ try testing.expectEqual(ItemType.attachment, item.encoded.type);
+ try item.decode(v.allocator());
+ try testing.expect(item.* == .attachment);
+ try testing.expectEqualStrings("test.txt", item.attachment.filename);
+
+ // Serialization test
+ {
+ var output: std.Io.Writer.Allocating = .init(alloc);
+ defer output.deinit();
+ try v.serialize(&output.writer);
+ try testing.expectEqualStrings(
+ \\{}
+ \\{"type":"attachment","length":4,"filename":"test.txt"}
+ \\ABCD
+ , std.mem.trim(u8, output.written(), "\n"));
+ }
+}
+
+test "Envelope serialize empty" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var reader: std.Io.Reader = .fixed(
+ \\{}
+ );
+ var v = try Envelope.parse(alloc, &reader);
+ defer v.deinit();
+
+ var output: std.Io.Writer.Allocating = .init(alloc);
+ defer output.deinit();
+ try v.serialize(&output.writer);
+
+ try testing.expectEqualStrings(
+ \\{}
+ , std.mem.trim(u8, output.written(), "\n"));
+}
+
+test "Envelope serialize session" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var reader: std.Io.Reader = .fixed(
+ \\{}
+ \\{"type":"session","length":218}
+ \\{"init":true,"sid":"c148cc2f-5f9f-4231-575c-2e85504d6434","status":"abnormal","errors":0,"started":"2024-08-29T02:38:57.607016Z","duration":0.000343,"attrs":{"release":"0.1.0-HEAD+d37b7d09","environment":"production"}}
+ );
+ var v = try Envelope.parse(alloc, &reader);
+ defer v.deinit();
+
+ var output: std.Io.Writer.Allocating = .init(alloc);
+ defer output.deinit();
+ try v.serialize(&output.writer);
+
+ try testing.expectEqualStrings(
+ \\{}
+ \\{"type":"session","length":218}
+ \\{"init":true,"sid":"c148cc2f-5f9f-4231-575c-2e85504d6434","status":"abnormal","errors":0,"started":"2024-08-29T02:38:57.607016Z","duration":0.000343,"attrs":{"release":"0.1.0-HEAD+d37b7d09","environment":"production"}}
+ , std.mem.trim(u8, output.written(), "\n"));
+}