codspeed 4.5.0

Core instrumentation library for CodSpeed
Documentation
const std = @import("std");
const fs = std.fs;
const logger = @import("../logger.zig");
const linked_libraries = @import("linked_libraries/root.zig");

extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8;

/// A value in the integration environment: either a single string or a list of strings.
pub const EnvironmentValue = union(enum) {
    string: []const u8,
    list: []const []const u8,

    pub fn jsonStringify(self: @This(), jw: anytype) !void {
        switch (self) {
            .string => |s| try jw.write(s),
            .list => |l| try jw.write(l),
        }
    }
};

/// Self-reported environment information provided by integrations (e.g. compiler version, runtime details).
const IntegrationEnvironmentEntries = std.json.ArrayHashMap(EnvironmentValue);
const IntegrationEnvironmentMap = std.json.ArrayHashMap(IntegrationEnvironmentEntries);

const LinkedLibrariesMap = std.json.ArrayHashMap(linked_libraries.LibraryEntry);

const EnvironmentJson = struct {
    integration_environment: IntegrationEnvironmentMap = .{},
    linked_libraries: LinkedLibrariesMap = .{},
};

pub const Environment = struct {
    allocator: std.mem.Allocator,
    data: EnvironmentJson = .{},
    libs: linked_libraries.LinkedLibraries,

    const Self = @This();

    pub fn init(alloc: std.mem.Allocator) Self {
        return .{
            .allocator = alloc,
            .libs = linked_libraries.LinkedLibraries.init(alloc),
        };
    }

    pub fn deinit(self: *Self) void {
        var int_it = self.data.integration_environment.map.iterator();
        while (int_it.next()) |int_entry| {
            var entry_it = int_entry.value_ptr.map.iterator();
            while (entry_it.next()) |kv| {
                self.allocator.free(kv.key_ptr.*);
                self.freeEnvironmentValue(kv.value_ptr.*);
            }
            int_entry.value_ptr.map.deinit(self.allocator);
            self.allocator.free(int_entry.key_ptr.*);
        }
        self.data.integration_environment.map.deinit(self.allocator);

        var ll_it = self.data.linked_libraries.map.iterator();
        while (ll_it.next()) |ll_entry| {
            self.allocator.free(ll_entry.key_ptr.*);
        }
        self.data.linked_libraries.map.deinit(self.allocator);

        self.libs.deinit();
    }

    pub fn setIntegrationEnvironment(self: *Self, integration_name: []const u8, key: []const u8, value: []const u8) !void {
        try self.setIntegrationEnvironmentValue(integration_name, key, .{ .string = try self.allocator.dupe(u8, value) });
    }

    pub fn setIntegrationEnvironmentList(self: *Self, integration_name: []const u8, key: []const u8, values: []const []const u8) !void {
        const duped = try self.allocator.alloc([]const u8, values.len);
        var i: usize = 0;
        errdefer {
            for (duped[0..i]) |item| self.allocator.free(item);
            self.allocator.free(duped);
        }
        while (i < values.len) : (i += 1) {
            duped[i] = try self.allocator.dupe(u8, values[i]);
        }
        try self.setIntegrationEnvironmentValue(integration_name, key, .{ .list = duped });
    }

    fn freeEnvironmentValue(self: *Self, val: EnvironmentValue) void {
        switch (val) {
            .string => |s| self.allocator.free(s),
            .list => |l| {
                for (l) |item| self.allocator.free(item);
                self.allocator.free(l);
            },
        }
    }

    fn setIntegrationEnvironmentValue(self: *Self, integration_name: []const u8, key: []const u8, value: EnvironmentValue) !void {
        const int_gop = try self.data.integration_environment.map.getOrPut(self.allocator, integration_name);
        if (!int_gop.found_existing) {
            int_gop.key_ptr.* = try self.allocator.dupe(u8, integration_name);
            int_gop.value_ptr.* = .{};
        }

        const entry_gop = try int_gop.value_ptr.map.getOrPut(self.allocator, key);
        if (entry_gop.found_existing) {
            self.freeEnvironmentValue(entry_gop.value_ptr.*);
        } else {
            entry_gop.key_ptr.* = try self.allocator.dupe(u8, key);
        }
        entry_gop.value_ptr.* = value;
    }

    fn populateLinkedLibraries(self: *Self) !void {
        // Clear existing entries
        var ll_it = self.data.linked_libraries.map.iterator();
        while (ll_it.next()) |ll_entry| {
            self.allocator.free(ll_entry.key_ptr.*);
        }
        self.data.linked_libraries.map.clearRetainingCapacity();

        for (self.libs.libraries.items) |lib| {
            const key = lib.soname orelse lib.path;
            const gop = try self.data.linked_libraries.map.getOrPut(self.allocator, key);
            gop.key_ptr.* = try self.allocator.dupe(u8, key);
            gop.value_ptr.* = .{
                .path = lib.path,
                .build_id = lib.build_id,
            };
        }
    }

    pub fn writeEnvironment(self: *Self, pid: u32) u8 {
        self.libs.collect() catch {
            logger.err("instrument-hooks: failed to collect linked libraries\n", .{});
        };
        self.populateLinkedLibraries() catch {
            logger.err("instrument-hooks: failed to populate linked libraries\n", .{});
        };

        if (self.data.integration_environment.map.count() == 0 and self.data.linked_libraries.map.count() == 0) return 0;

        const profile_folder = getenv("CODSPEED_PROFILE_FOLDER") orelse {
            return 0;
        };

        const folder_slice = std.mem.span(profile_folder);

        var path_buf: [512]u8 = undefined;
        const path = std.fmt.bufPrint(&path_buf, "{s}/environment-{d}.json", .{ folder_slice, pid }) catch {
            logger.err("instrument-hooks: profile folder path too long\n", .{});
            return 1;
        };

        // Serialize and write
        const json = std.json.stringifyAlloc(self.allocator, self.data, .{ .whitespace = .indent_2 }) catch {
            logger.err("instrument-hooks: failed to serialize environment JSON\n", .{});
            return 1;
        };
        defer self.allocator.free(json);

        const file = fs.createFileAbsolute(path, .{}) catch {
            logger.err("instrument-hooks: failed to write environment.json\n", .{});
            return 1;
        };
        defer file.close();

        file.writeAll(json) catch {
            logger.err("instrument-hooks: failed to write environment.json\n", .{});
            return 1;
        };

        return 0;
    }
};

// --- Tests ---

test "set and retrieve section entries" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    try env.setIntegrationEnvironment("gcc", "version", "14.2.0");
    try env.setIntegrationEnvironment("gcc", "build", "g++ (Ubuntu 14.2.0-4ubuntu2) 14.2.0");
    try env.setIntegrationEnvironment("clang", "version", "18.1.0");

    try std.testing.expectEqual(@as(usize, 2), env.data.integration_environment.map.count());
    try std.testing.expectEqual(@as(usize, 2), env.data.integration_environment.map.get("gcc").?.map.count());
    try std.testing.expectEqual(@as(usize, 1), env.data.integration_environment.map.get("clang").?.map.count());
}

test "overwrite existing entry" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    try env.setIntegrationEnvironment("gcc", "version", "13.0.0");
    try env.setIntegrationEnvironment("gcc", "version", "14.2.0");

    try std.testing.expectEqual(@as(usize, 1), env.data.integration_environment.map.count());
    try std.testing.expectEqualStrings("14.2.0", env.data.integration_environment.map.get("gcc").?.map.get("version").?.string);
}

test "json serialization" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    try env.setIntegrationEnvironment("gcc", "version", "14.2.0");
    try env.setIntegrationEnvironment("gcc", "build", "g++ (Ubuntu 14.2.0)");
    try env.setIntegrationEnvironment("clang", "version", "18.1.0");

    const json = try std.json.stringifyAlloc(std.testing.allocator, env.data, .{ .whitespace = .indent_2 });
    defer std.testing.allocator.free(json);

    try std.testing.expect(std.mem.indexOf(u8, json, "\"version\": \"14.2.0\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"build\": \"g++ (Ubuntu 14.2.0)\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"version\": \"18.1.0\"") != null);
}

test "empty sections" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    const json = try std.json.stringifyAlloc(std.testing.allocator, env.data, .{ .whitespace = .indent_2 });
    defer std.testing.allocator.free(json);

    try std.testing.expectEqualStrings(
        \\{
        \\  "integration_environment": {},
        \\  "linked_libraries": {}
        \\}
    , json);
}

test "json escaping" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    try env.setIntegrationEnvironment("test", "path", "C:\\Program Files\\gcc");

    const json = try std.json.stringifyAlloc(std.testing.allocator, env.data, .{ .whitespace = .indent_2 });
    defer std.testing.allocator.free(json);

    // Backslashes should be escaped in JSON
    try std.testing.expect(std.mem.indexOf(u8, json, "C:\\\\Program Files\\\\gcc") != null);
}

test "merge preserves existing and adds new" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    // Simulate existing data parsed from file
    try env.setIntegrationEnvironment("python", "version", "3.12.0");

    // Add new section
    try env.setIntegrationEnvironment("cpp", "version", "14.2.0");

    try std.testing.expectEqual(@as(usize, 2), env.data.integration_environment.map.count());

    const json = try std.json.stringifyAlloc(std.testing.allocator, env.data, .{ .whitespace = .indent_2 });
    defer std.testing.allocator.free(json);

    try std.testing.expect(std.mem.indexOf(u8, json, "\"python\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"cpp\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"3.12.0\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"14.2.0\"") != null);
}

test "new entries override existing on merge" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    try env.setIntegrationEnvironment("python", "version", "3.12.0");
    try env.setIntegrationEnvironment("python", "version", "3.13.0");

    try std.testing.expectEqual(@as(usize, 1), env.data.integration_environment.map.count());
    try std.testing.expectEqualStrings("3.13.0", env.data.integration_environment.map.get("python").?.map.get("version").?.string);
}

test "list environment value" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    try env.setIntegrationEnvironmentList("python", "sys_path", &.{ "/usr/lib/python3.13", "/home/user/.venv/lib" });
    try env.setIntegrationEnvironment("python", "version", "3.13.0");

    const json = try std.json.stringifyAlloc(std.testing.allocator, env.data, .{ .whitespace = .indent_2 });
    defer std.testing.allocator.free(json);

    try std.testing.expect(std.mem.indexOf(u8, json, "\"version\": \"3.13.0\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"sys_path\": [") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"/usr/lib/python3.13\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"/home/user/.venv/lib\"") != null);
}

test "overwrite string with list" {
    var env = Environment.init(std.testing.allocator);
    defer env.deinit();

    try env.setIntegrationEnvironment("python", "paths", "old_value");
    try env.setIntegrationEnvironmentList("python", "paths", &.{ "/a", "/b" });

    try std.testing.expectEqual(@as(usize, 1), env.data.integration_environment.map.get("python").?.map.count());
    const val = env.data.integration_environment.map.get("python").?.map.get("paths").?;
    try std.testing.expectEqual(@as(usize, 2), val.list.len);
}

test "linked libraries serialization" {
    const alloc = std.testing.allocator;
    var env = Environment.init(alloc);
    defer env.deinit();

    try env.libs.libraries.append(.{
        .path = try alloc.dupe(u8, "/usr/lib/libc.so.6"),
        .soname = try alloc.dupe(u8, "libc.so.6"),
        .build_id = try alloc.dupe(u8, "abc123"),
    });

    try env.libs.libraries.append(.{
        .path = try alloc.dupe(u8, "/usr/lib/libm.so.6"),
        .soname = null,
        .build_id = null,
    });

    try env.populateLinkedLibraries();

    const json = try std.json.stringifyAlloc(alloc, env.data, .{ .whitespace = .indent_2 });
    defer alloc.free(json);

    // Library with soname is keyed by soname
    try std.testing.expect(std.mem.indexOf(u8, json, "\"libc.so.6\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"/usr/lib/libc.so.6\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, json, "\"abc123\"") != null);

    // Library without soname is keyed by path
    try std.testing.expect(std.mem.indexOf(u8, json, "\"/usr/lib/libm.so.6\"") != null);
}