Skip to content

Commit 748a060

Browse files
committed
Initial usage telemetry
1 parent 6ae4ed9 commit 748a060

File tree

5 files changed

+259
-1
lines changed

5 files changed

+259
-1
lines changed

src/app.zig

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const std = @import("std");
2+
3+
const Allocator = std.mem.Allocator;
4+
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
5+
6+
// Container for global state / objects that various parts of the system
7+
// might need.
8+
pub const App = struct {
9+
telemetry: Telemetry,
10+
11+
pub fn init(allocator: Allocator) !App {
12+
const telemetry = Telemetry.init(allocator);
13+
errdefer telemetry.deinit();
14+
15+
return .{
16+
.telemetry = telemetry,
17+
};
18+
}
19+
20+
pub fn deinit(self: *App) void {
21+
self.telemetry.deinit();
22+
}
23+
};

src/main.zig

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const apiweb = @import("apiweb.zig");
3131
pub const Types = jsruntime.reflect(apiweb.Interfaces);
3232
pub const UserContext = apiweb.UserContext;
3333
pub const IO = @import("asyncio").Wrapper(jsruntime.Loop);
34+
const version = @import("build_info").git_commit;
3435

3536
const log = std.log.scoped(.cli);
3637

@@ -53,17 +54,21 @@ pub fn main() !void {
5354
_ = gpa.detectLeaks();
5455
};
5556

57+
var app = try @import("app.zig").App.init(alloc);
58+
defer app.deinit();
59+
5660
var args_arena = std.heap.ArenaAllocator.init(alloc);
5761
defer args_arena.deinit();
5862
const args = try parseArgs(args_arena.allocator());
5963

6064
switch (args.mode) {
6165
.help => args.printUsageAndExit(args.mode.help),
6266
.version => {
63-
std.debug.print("{s}\n", .{@import("build_info").git_commit});
67+
std.debug.print("{s}\n", .{version});
6468
return std.process.cleanExit();
6569
},
6670
.serve => |opts| {
71+
app.telemetry.record(.{ .run = .{ .mode = .serve, .version = version } });
6772
const address = std.net.Address.parseIp4(opts.host, opts.port) catch |err| {
6873
log.err("address (host:port) {any}\n", .{err});
6974
return args.printUsageAndExit(false);
@@ -79,6 +84,7 @@ pub fn main() !void {
7984
};
8085
},
8186
.fetch => |opts| {
87+
app.telemetry.record(.{ .run = .{ .mode = .fetch, .version = version } });
8288
log.debug("Fetch mode: url {s}, dump {any}", .{ opts.url, opts.dump });
8389

8490
// vm

src/telemetry/lightpanda.zig

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const std = @import("std");
2+
const Allocator = std.mem.Allocator;
3+
const ArenAallocator = std.heap.ArenaAllocator;
4+
5+
const Event = @import("telemetry.zig").Event;
6+
const log = std.log.scoped(.telemetry);
7+
8+
const URL = "https://lightpanda.io/browser-stats";
9+
10+
pub const Lightpanda = struct {
11+
uri: std.Uri,
12+
arena: ArenAallocator,
13+
client: std.http.Client,
14+
headers: [1]std.http.Header,
15+
16+
pub fn init(allocator: Allocator) !Lightpanda {
17+
return .{
18+
.client = .{ .allocator = allocator },
19+
.arena = std.heap.ArenaAllocator.init(allocator),
20+
.uri = std.Uri.parse(URL) catch unreachable,
21+
.headers = [1]std.http.Header{
22+
.{ .name = "Content-Type", .value = "application/json" },
23+
},
24+
};
25+
}
26+
27+
pub fn deinit(self: *Lightpanda) void {
28+
self.arena.deinit();
29+
self.client.deinit();
30+
}
31+
32+
pub fn send(self: *Lightpanda, iid: ?[]const u8, eid: []const u8, events: []Event) !void {
33+
std.debug.print("SENDING: {s} {s} {d}", .{iid, eid, events.len})
34+
// defer _ = self.arena.reset(.{ .retain_capacity = {} });
35+
// const body = try std.json.stringifyAlloc(self.arena.allocator(), PlausibleEvent{ .event = event }, .{});
36+
37+
// var server_headers: [2048]u8 = undefined;
38+
// var req = try self.client.open(.POST, self.uri, .{
39+
// .redirect_behavior = .not_allowed,
40+
// .extra_headers = &self.headers,
41+
// .server_header_buffer = &server_headers,
42+
// });
43+
// req.transfer_encoding = .{ .content_length = body.len };
44+
// try req.send();
45+
46+
// try req.writeAll(body);
47+
// try req.finish();
48+
// try req.wait();
49+
50+
// const status = req.response.status;
51+
// if (status != .accepted) {
52+
// log.warn("telemetry '{s}' event error: {d}", .{ @tagName(event), @intFromEnum(status) });
53+
// } else {
54+
// log.warn("telemetry '{s}' sent", .{@tagName(event)});
55+
// }
56+
}
57+
};
58+
59+
// wraps a telemetry event so that we can serialize it to plausible's event endpoint
60+
const PlausibleEvent = struct {
61+
event: Event,
62+
63+
pub fn jsonStringify(self: PlausibleEvent, jws: anytype) !void {
64+
try jws.beginObject();
65+
try jws.objectField("name");
66+
try jws.write(@tagName(self.event));
67+
try jws.objectField("url");
68+
try jws.write(EVENT_URL);
69+
try jws.objectField("domain");
70+
try jws.write(DOMAIN_KEY);
71+
try jws.objectField("props");
72+
switch (self.event) {
73+
inline else => |props| try jws.write(props),
74+
}
75+
try jws.endObject();
76+
}
77+
};
78+
79+
const testing = std.testing;
80+
test "plausible: json event" {
81+
const json = try std.json.stringifyAlloc(testing.allocator, PlausibleEvent{ .event = .{ .run = .{ .mode = .serve, .version = "over 9000!" } } }, .{});
82+
defer testing.allocator.free(json);
83+
84+
try testing.expectEqualStrings(
85+
\\{"name":"run","url":"https://lightpanda.io/browser-stats","domain":"localhost","props":{"version":"over 9000!","mode":"serve"}}
86+
, json);
87+
}

src/telemetry/telemetry.zig

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
const std = @import("std");
2+
const builtin = @import("builtin");
3+
4+
const Allocator = std.mem.Allocator;
5+
const uuidv4 = @import("../id.zig").uuidv4;
6+
7+
const log = std.log.scoped(.telemetry);
8+
9+
const BATCH_SIZE = 5;
10+
const BATCH_END = BATCH_SIZE - 1;
11+
const ID_FILE = "lightpanda.id";
12+
13+
pub const Telemetry = TelemetryT(blk: {
14+
if (builtin.mode == .Debug or builtin.is_test) break :blk NoopProvider;
15+
break :blk @import("ligtpanda.zig").Lightpanda;
16+
});
17+
18+
fn TelemetryT(comptime P: type) type {
19+
return struct {
20+
// an "install" id that we [try to] persist and re-use between runs
21+
// null on IO error
22+
iid: ?[36]u8,
23+
24+
// a "execution" id is an id that represents this specific run
25+
eid: [36]u8,
26+
provider: P,
27+
28+
// batch of events, pending[0..count] are pending
29+
pending: [BATCH_SIZE]Event,
30+
count: usize,
31+
disabled: bool,
32+
33+
const Self = @This();
34+
35+
pub fn init(allocator: Allocator) Self {
36+
const disabled = std.process.hasEnvVarConstant("LIGHTPANDA_DISABLE_TELEMETRY");
37+
38+
var eid: [36]u8 = undefined;
39+
uuidv4(&eid)
40+
41+
return .{
42+
.eid = eid,
43+
.iid = if (disabled) null else getOrCreateId(),
44+
.disabled = disabled,
45+
.provider = try P.init(allocator),
46+
};
47+
}
48+
49+
pub fn deinit(self: *Self) void {
50+
self.provider.deinit();
51+
}
52+
53+
pub fn record(self: *Self, event: Event) void {
54+
if (self.disabled) {
55+
return;
56+
}
57+
58+
const count = self.count;
59+
self.pending[count] = event;
60+
if (count < BATCH_END) {
61+
self.count = count + 1;
62+
return;
63+
}
64+
65+
const iid = if (self.iid) |*iid| *iid else null;
66+
self.provider.send(iid, &self.eid, &self.pending) catch |err| {
67+
log.warn("failed to record event: {}", .{err});
68+
};
69+
self.count = 0;
70+
}
71+
};
72+
}
73+
74+
fn getOrCreateId() ?[36]u8 {
75+
var buf: [37]u8 = undefined;
76+
const data = std.fs.cwd().readFile(ID_FILE, &buf) catch |err| switch (err) blk: {
77+
error.FileNotFound => break :bkl &.{},
78+
else => {
79+
log.warn("failed to open id file: {}", .{err});
80+
return null,
81+
},
82+
}
83+
84+
var id: [36]u8 = undefined;
85+
if (data.len == 36) {
86+
@memcpy(id[0..36], data)
87+
return id;
88+
}
89+
90+
uuidv4(&id);
91+
std.fs.cwd().writeFile(.{.sub_path = ID_FILE, .data = buf[0..36]}) catch |err| {
92+
log.warn("failed to write to id file: {}", .{err});
93+
return null;
94+
};
95+
return id;
96+
}
97+
98+
pub const Event = union(enum) {
99+
run: Run,
100+
101+
const Run = struct {
102+
version: []const u8,
103+
mode: RunMode,
104+
105+
const RunMode = enum {
106+
fetch,
107+
serve,
108+
};
109+
};
110+
};
111+
112+
const NoopProvider = struct {
113+
fn init(_: Allocator) !NoopProvider {
114+
return .{};
115+
}
116+
fn deinit(_: NoopProvider) void {}
117+
pub fn record(_: NoopProvider, _: Event) !void {}
118+
};
119+
120+
extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
121+
extern fn unsetenv(name: [*:0]u8) c_int;
122+
const testing = std.testing;
123+
test "telemetry: disabled by environment" {
124+
_ = setenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"), @constCast(""), 0);
125+
defer _ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"));
126+
127+
const FailingProvider = struct {
128+
fn init(_: Allocator) !@This() {
129+
return .{};
130+
}
131+
fn deinit(_: @This()) void {}
132+
pub fn record(_: @This(), _: Event) !void {
133+
unreachable;
134+
}
135+
};
136+
137+
var telemetry = TelemetryT(FailingProvider).init(testing.allocator);
138+
defer telemetry.deinit();
139+
telemetry.record(.{ .run = .{ .mode = .serve, .version = "123" } });
140+
}

src/unit_tests.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,6 @@ test {
382382
std.testing.refAllDecls(@import("server.zig"));
383383
std.testing.refAllDecls(@import("cdp/cdp.zig"));
384384
std.testing.refAllDecls(@import("log.zig"));
385+
std.testing.refAllDecls(@import("telemetry/telemetry.zig"));
386+
std.testing.refAllDecls(@import("telemetry/plausible.zig"));
385387
}

0 commit comments

Comments
 (0)