prometheus API reference Changelog View on GitHub

Prometheus text exposition format for Zig: write metrics to any writer, no HTTP.

The exposition bytes are the part every service writes by hand, and they are where the bugs are. One unescaped newline in a label value takes down every series in the scrape, not one line, and Zig's own {d} writes nan for NaN and 301 characters for 1e300. This package owns those two rules and the escaping around them. It writes to any std.Io.Writer, so a buffer, a file, and a socket are the caller's problem.

It is a cursor, not a registry. Writer.writeMetric writes one line, writeFamily writes # HELP and # TYPE and hands back a writer for that family's samples, and Writer.writeHistogram writes a classic histogram. Which families appear, in what order, and whether a family has help text are the caller's business.

The API reference documents every public symbol.

Getting started

Install

zig fetch --save https://baskingspot.dev/prometheus/v0.1.0.tar.gz

Add the module in build.zig:

const prometheus = b.dependency("prometheus", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("prometheus", prometheus.module("prometheus"));

prometheus builds with Zig 0.16 and 0.17. Its one dependency is testingx, which is lazy and comes after an early return in build.zig, so a consumer fetches nothing but the module.

Import it

const prometheus = @import("prometheus");

No std namespace is named prometheus, so nothing needs an alias.

Pick a sink

Every writer takes *std.Io.Writer and returns error{WriteFailed}. A fixed buffer holds the document with no allocation:

var buffer: [4096]u8 = undefined;
var out: std.Io.Writer = .fixed(&buffer);
var w: prometheus.Writer = .init(&out);

Writer is the whole interface above the sink: it holds the *std.Io.Writer and nothing else.

std.Io.Writer.Allocating grows a body you will own, and std.Io.File.Writer writes to a file or a socket. The library never sees either.

const std = @import("std");

const prometheus = @import("prometheus");

test "a fixed buffer, one line at a time" {
    var buffer: [1024]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    var w: prometheus.Writer = .init(&out);

    try w.writeMetric("up", .{}, 1);

    try std.testing.expectEqualStrings("up 1\n", out.buffered());
}

test "an allocating writer grows a body you own" {
    var buffer: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer buffer.deinit();
    var w: prometheus.Writer = .init(&buffer.writer);

    try w.writeMetric("up", .{}, 1);

    try std.testing.expectEqualStrings("up 1\n", buffer.written());
}

Read the examples

The examples this page embeds are in docs/examples/. just test compiles and runs them with the module's own tests, so no page can show code that does not build.

Samples

One line

Writer.writeMetric writes a name, its labels, a value, and a newline.

const std = @import("std");

const prometheus = @import("prometheus");

test "a fixed buffer, one line at a time" {
    var buffer: [1024]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    var w: prometheus.Writer = .init(&out);

    try w.writeMetric("up", .{}, 1);

    try std.testing.expectEqualStrings("up 1\n", out.buffered());
}

test "an allocating writer grows a body you own" {
    var buffer: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer buffer.deinit();
    var w: prometheus.Writer = .init(&buffer.writer);

    try w.writeMetric("up", .{}, 1);

    try std.testing.expectEqualStrings("up 1\n", buffer.written());
}

The trailing newline is not optional: the parser rejects a document whose last line has none, and every writer here adds one.

Labels come from a struct

The labels argument is any struct: each field name is a label name, in declaration order, and each field value is the label value. A string field is escaped and an integer field is printed in decimal, inside the quotes the parser expects. A struct with no fields writes no braces at all, so up 1 and not up{} 1.

A label name is a struct field. The grammar is [a-zA-Z_][a-zA-Z0-9_]*, the __ prefix is reserved (__name__ is the series name the parser builds from the line, so it rejects a label that claims it), and the compiler enforces both: a field written as @"not-a-label" or .__name__ does not compile.

Families

The header, then the samples

Writer.writeFamily writes # HELP and # TYPE and returns a handle. Its write method is writeMetric with the family's name, so a sample line cannot be written under another name by accident.

const std = @import("std");

const prometheus = @import("prometheus");

test "one family: the header, then its samples" {
    var buffer: [4096]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    var w: prometheus.Writer = .init(&out);

    const requests = try w.writeFamily(.{
        .name = "http_requests_total",
        .kind = .counter,
        .help = "Requests served, by method and status.",
    });
    try requests.write(.{ .method = "GET", .status = "200" }, 1841);
    try requests.write(.{ .method = "POST", .status = "500" }, 3);

    try std.testing.expectEqualStrings(
        \\# HELP http_requests_total Requests served, by method and status.
        \\# TYPE http_requests_total counter
        \\http_requests_total{method="GET",status="200"} 1841
        \\http_requests_total{method="POST",status="500"} 3
        \\
    , out.buffered());
}

An empty help writes the # HELP line without text. The kind is one of counter, gauge, histogram, and untyped, and it goes on the # TYPE line as written.

Ordering is yours

Nothing here knows which families you have written or in what order. Two calls to writeFamily with the same name write two headers, and the parser accepts it; a sample written through the second handle lands after the second header. The handle exists to save typing the name and to keep the field order, not to enforce a document shape.

Histograms

Cumulative counts, written as buckets

Writer.writeHistogram writes the header, then one _bucket line per limit, the implicit +Inf bucket, _sum, and _count. The counts are cumulative: counts[i] is the number of observations at or below limits[i]. The last finite count is at most the total, because an observation above the top limit belongs only to the +Inf bucket, which is written from count.

const std = @import("std");

const prometheus = @import("prometheus");

test "a histogram family from the caller's cumulative counts" {
    var buffer: [4096]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    var w: prometheus.Writer = .init(&out);

    try w.writeHistogram(.{
        .name = "request_latency_seconds",
        .kind = .histogram,
        .help = "Request latency.",
    }, .{ .method = "GET" }, &.{ 0.005, 0.05 }, &[_]u64{ 12, 40 }, 3.5, 40);

    try std.testing.expectEqualStrings(
        \\# HELP request_latency_seconds Request latency.
        \\# TYPE request_latency_seconds histogram
        \\request_latency_seconds_bucket{method="GET",le="0.005"} 12
        \\request_latency_seconds_bucket{method="GET",le="0.05"} 40
        \\request_latency_seconds_bucket{method="GET",le="+Inf"} 40
        \\request_latency_seconds_sum{method="GET"} 3.5
        \\request_latency_seconds_count{method="GET"} 40
        \\
    , out.buffered());
}

An empty limits slice writes one +Inf bucket, _sum, and _count.

More than one series

Writer.writeHistogram writes the header and one series, so a family with two label sets cannot call it twice: the second call writes # HELP and # TYPE again and the parser rejects the document. Write the header once with w.writeFamily and loop the series form.

const family = try w.writeFamily(.{ .name = "message_size", .kind = .histogram });
try family.writeHistogramSeries(.{ .direction = "inbound" }, &.{1024}, &[_]u64{1}, 1024, 1);
try family.writeHistogramSeries(.{ .direction = "outbound" }, &.{1024}, &[_]u64{0}, 3072, 2);

sum is anytype: pass a u64 and every digit is written, because a f64 loses everything above 2^53. A float is formatted the way writeMetric formats one.

What is checked, and what is not

A length mismatch between limits and counts is a caller bug caught by a debug assertion. Monotonicity, the size of the tail, and whether sum corresponds to the observations are not checked: they are your counters, and a library that second-guessed them would have to choose what to do about it.

Two things do not compile, because the bytes would be wrong and no caller could repair them after the fact. A labels struct carrying le is refused: the writer adds le to every bucket line, and a second one on the same line is a duplicate label name the parser rejects. A Metric whose kind is not .histogram is refused by the histogram writers: # TYPE would say counter or gauge while the _bucket lines below it said otherwise.

Values and escaping

Values

A float that reads back as the same integer is written as that integer. 1e6 is 1000000, 3.0 is 3, and -0.0 is 0. Everything else is Go's 'g' with the shortest round-trip digits, in exponent form when the decimal exponent is below -4 or at least 6, so 1e21 is 1e+21, 1e300 is 1e+300, and 0.0001 stays as it is.

NaN, +Inf, and -Inf are written exactly so.

An integer type never goes through f64, so a u64 above 2^53 prints every digit. A Go client passing that count as a float writes 9007199254740992, because it has no integer path.

Escaping

Three bytes are escaped inside a label value: backslash, double quote, and newline. Two are escaped in help text: backslash and newline. Everything else is written raw, including tab, carriage return, NUL, and any valid UTF-8.

const std = @import("std");

const prometheus = @import("prometheus");

test "a label value with a quote, a newline, and a backslash" {
    var buffer: [1024]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    var w: prometheus.Writer = .init(&out);

    try w.writeMetric("worker_log", .{
        .message = "say \"hi\"\nthen\\leave",
    }, 1);

    try std.testing.expectEqualStrings(
        "worker_log{message=\"say \\\"hi\\\"\\nthen\\\\leave\"} 1\n",
        out.buffered(),
    );
}

A label value must be valid UTF-8. The writer does not check it: a check inside the writer would turn one crafted value into a debug abort, which is worse than the poisoned document it replaces because it is not survivable, and a rule about a caller's data does not belong there. Validate where the data arrives, and answer with whatever your boundary answers for a bad request.

The primitives

Writer writes one expfmt record per call and knows nothing else:

Every composition on this page is built from those five, and a test holds it to that: the first _bucket line Writer.writeHistogram writes must equal the same line written by hand with name and value. A caller that needs a shape this library does not have, such as a summary or an exemplar, writes it from the primitives and gets the same values and escaping.

var out: std.Io.Writer = .fixed(&buffer);
var w: prometheus.Writer = .init(&out);
try w.name("custom_total", .{ .kind = "custom" });
try w.value(@as(u64, 7));

Limits

What is not here, named so nobody has to look.