diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/crash | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/crash')
| -rw-r--r-- | vendor/libghostty-vt/src/crash/dir.zig | 66 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/crash/main.zig | 21 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/crash/sentry.zig | 317 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/crash/sentry_envelope.zig | 597 |
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")); +} |
