diff --git a/src/app.zig b/src/app.zig new file mode 100644 index 00000000..a92f3b9f --- /dev/null +++ b/src/app.zig @@ -0,0 +1,23 @@ +const std = @import("std"); + +const Allocator = std.mem.Allocator; +const Telemetry = @import("telemetry/telemetry.zig").Telemetry; + +// Container for global state / objects that various parts of the system +// might need. +pub const App = struct { + telemetry: Telemetry, + + pub fn init(allocator: Allocator) !App { + const telemetry = Telemetry.init(allocator); + errdefer telemetry.deinit(); + + return .{ + .telemetry = telemetry, + }; + } + + pub fn deinit(self: *App) void { + self.telemetry.deinit(); + } +}; diff --git a/src/main.zig b/src/main.zig index 624d0ce1..2aaf879c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -31,6 +31,7 @@ const apiweb = @import("apiweb.zig"); pub const Types = jsruntime.reflect(apiweb.Interfaces); pub const UserContext = apiweb.UserContext; pub const IO = @import("asyncio").Wrapper(jsruntime.Loop); +const version = @import("build_info").git_commit; const log = std.log.scoped(.cli); @@ -53,6 +54,9 @@ pub fn main() !void { _ = gpa.detectLeaks(); }; + var app = try @import("app.zig").App.init(alloc); + defer app.deinit(); + var args_arena = std.heap.ArenaAllocator.init(alloc); defer args_arena.deinit(); const args = try parseArgs(args_arena.allocator()); @@ -60,10 +64,11 @@ pub fn main() !void { switch (args.mode) { .help => args.printUsageAndExit(args.mode.help), .version => { - std.debug.print("{s}\n", .{@import("build_info").git_commit}); + std.debug.print("{s}\n", .{version}); return std.process.cleanExit(); }, .serve => |opts| { + app.telemetry.record(.{ .run = .{ .mode = .serve, .version = version } }); const address = std.net.Address.parseIp4(opts.host, opts.port) catch |err| { log.err("address (host:port) {any}\n", .{err}); return args.printUsageAndExit(false); @@ -79,6 +84,7 @@ pub fn main() !void { }; }, .fetch => |opts| { + app.telemetry.record(.{ .run = .{ .mode = .fetch, .version = version } }); log.debug("Fetch mode: url {s}, dump {any}", .{ opts.url, opts.dump }); // vm diff --git a/src/telemetry/lightpanda.zig b/src/telemetry/lightpanda.zig new file mode 100644 index 00000000..b3f481a7 --- /dev/null +++ b/src/telemetry/lightpanda.zig @@ -0,0 +1,87 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenAallocator = std.heap.ArenaAllocator; + +const Event = @import("telemetry.zig").Event; +const log = std.log.scoped(.telemetry); + +const URL = "https://lightpanda.io/browser-stats"; + +pub const Lightpanda = struct { + uri: std.Uri, + arena: ArenAallocator, + client: std.http.Client, + headers: [1]std.http.Header, + + pub fn init(allocator: Allocator) !Lightpanda { + return .{ + .client = .{ .allocator = allocator }, + .arena = std.heap.ArenaAllocator.init(allocator), + .uri = std.Uri.parse(URL) catch unreachable, + .headers = [1]std.http.Header{ + .{ .name = "Content-Type", .value = "application/json" }, + }, + }; + } + + pub fn deinit(self: *Lightpanda) void { + self.arena.deinit(); + self.client.deinit(); + } + + pub fn send(self: *Lightpanda, iid: ?[]const u8, eid: []const u8, events: []Event) !void { + std.debug.print("SENDING: {s} {s} {d}", .{iid, eid, events.len}) + // defer _ = self.arena.reset(.{ .retain_capacity = {} }); + // const body = try std.json.stringifyAlloc(self.arena.allocator(), PlausibleEvent{ .event = event }, .{}); + + // var server_headers: [2048]u8 = undefined; + // var req = try self.client.open(.POST, self.uri, .{ + // .redirect_behavior = .not_allowed, + // .extra_headers = &self.headers, + // .server_header_buffer = &server_headers, + // }); + // req.transfer_encoding = .{ .content_length = body.len }; + // try req.send(); + + // try req.writeAll(body); + // try req.finish(); + // try req.wait(); + + // const status = req.response.status; + // if (status != .accepted) { + // log.warn("telemetry '{s}' event error: {d}", .{ @tagName(event), @intFromEnum(status) }); + // } else { + // log.warn("telemetry '{s}' sent", .{@tagName(event)}); + // } + } +}; + +// wraps a telemetry event so that we can serialize it to plausible's event endpoint +const PlausibleEvent = struct { + event: Event, + + pub fn jsonStringify(self: PlausibleEvent, jws: anytype) !void { + try jws.beginObject(); + try jws.objectField("name"); + try jws.write(@tagName(self.event)); + try jws.objectField("url"); + try jws.write(EVENT_URL); + try jws.objectField("domain"); + try jws.write(DOMAIN_KEY); + try jws.objectField("props"); + switch (self.event) { + inline else => |props| try jws.write(props), + } + try jws.endObject(); + } +}; + +const testing = std.testing; +test "plausible: json event" { + const json = try std.json.stringifyAlloc(testing.allocator, PlausibleEvent{ .event = .{ .run = .{ .mode = .serve, .version = "over 9000!" } } }, .{}); + defer testing.allocator.free(json); + + try testing.expectEqualStrings( + \\{"name":"run","url":"https://lightpanda.io/browser-stats","domain":"localhost","props":{"version":"over 9000!","mode":"serve"}} + , json); +} diff --git a/src/telemetry/telemetry.zig b/src/telemetry/telemetry.zig new file mode 100644 index 00000000..90d55c15 --- /dev/null +++ b/src/telemetry/telemetry.zig @@ -0,0 +1,140 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const Allocator = std.mem.Allocator; +const uuidv4 = @import("../id.zig").uuidv4; + +const log = std.log.scoped(.telemetry); + +const BATCH_SIZE = 5; +const BATCH_END = BATCH_SIZE - 1; +const ID_FILE = "lightpanda.id"; + +pub const Telemetry = TelemetryT(blk: { + if (builtin.mode == .Debug or builtin.is_test) break :blk NoopProvider; + break :blk @import("ligtpanda.zig").Lightpanda; +}); + +fn TelemetryT(comptime P: type) type { + return struct { + // an "install" id that we [try to] persist and re-use between runs + // null on IO error + iid: ?[36]u8, + + // a "execution" id is an id that represents this specific run + eid: [36]u8, + provider: P, + + // batch of events, pending[0..count] are pending + pending: [BATCH_SIZE]Event, + count: usize, + disabled: bool, + + const Self = @This(); + + pub fn init(allocator: Allocator) Self { + const disabled = std.process.hasEnvVarConstant("LIGHTPANDA_DISABLE_TELEMETRY"); + + var eid: [36]u8 = undefined; + uuidv4(&eid) + + return .{ + .eid = eid, + .iid = if (disabled) null else getOrCreateId(), + .disabled = disabled, + .provider = try P.init(allocator), + }; + } + + pub fn deinit(self: *Self) void { + self.provider.deinit(); + } + + pub fn record(self: *Self, event: Event) void { + if (self.disabled) { + return; + } + + const count = self.count; + self.pending[count] = event; + if (count < BATCH_END) { + self.count = count + 1; + return; + } + + const iid = if (self.iid) |*iid| *iid else null; + self.provider.send(iid, &self.eid, &self.pending) catch |err| { + log.warn("failed to record event: {}", .{err}); + }; + self.count = 0; + } + }; +} + +fn getOrCreateId() ?[36]u8 { + var buf: [37]u8 = undefined; + const data = std.fs.cwd().readFile(ID_FILE, &buf) catch |err| switch (err) blk: { + error.FileNotFound => break :bkl &.{}, + else => { + log.warn("failed to open id file: {}", .{err}); + return null, + }, + } + + var id: [36]u8 = undefined; + if (data.len == 36) { + @memcpy(id[0..36], data) + return id; + } + + uuidv4(&id); + std.fs.cwd().writeFile(.{.sub_path = ID_FILE, .data = buf[0..36]}) catch |err| { + log.warn("failed to write to id file: {}", .{err}); + return null; + }; + return id; +} + +pub const Event = union(enum) { + run: Run, + + const Run = struct { + version: []const u8, + mode: RunMode, + + const RunMode = enum { + fetch, + serve, + }; + }; +}; + +const NoopProvider = struct { + fn init(_: Allocator) !NoopProvider { + return .{}; + } + fn deinit(_: NoopProvider) void {} + pub fn record(_: NoopProvider, _: Event) !void {} +}; + +extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int; +extern fn unsetenv(name: [*:0]u8) c_int; +const testing = std.testing; +test "telemetry: disabled by environment" { + _ = setenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"), @constCast(""), 0); + defer _ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY")); + + const FailingProvider = struct { + fn init(_: Allocator) !@This() { + return .{}; + } + fn deinit(_: @This()) void {} + pub fn record(_: @This(), _: Event) !void { + unreachable; + } + }; + + var telemetry = TelemetryT(FailingProvider).init(testing.allocator); + defer telemetry.deinit(); + telemetry.record(.{ .run = .{ .mode = .serve, .version = "123" } }); +} diff --git a/src/unit_tests.zig b/src/unit_tests.zig index 440a1f41..d677e5d7 100644 --- a/src/unit_tests.zig +++ b/src/unit_tests.zig @@ -384,4 +384,5 @@ test { std.testing.refAllDecls(@import("cdp/cdp.zig")); std.testing.refAllDecls(@import("log.zig")); std.testing.refAllDecls(@import("datetime.zig")); + std.testing.refAllDecls(@import("telemetry/telemetry.zig")); }