1/**
2 * Pure encoding helpers for the Prometheus remote-write wire format.
3 *
4 * Remote-write bodies are a `prometheus.WriteRequest` protobuf message,
5 * snappy-compressed (raw block format). Both encodings are small and fully
6 * specified, so they are hand-rolled here rather than pulling in protobuf +
7 * snappy dependencies:
8 *
9 * ```proto
10 * message WriteRequest { repeated TimeSeries timeseries = 1; }
11 * message TimeSeries { repeated Label labels = 1; repeated Sample samples = 2; }
12 * message Label { string name = 1; string value = 2; }
13 * message Sample { double value = 1; int64 timestamp = 2; }
14 * ```
15 *
16 * The snappy encoder emits literal-only blocks (no back-references), which is
17 * valid snappy — decoders don't require compressed output. Remote-write
18 * payloads are small enough that the size cost is irrelevant.
19 *
20 * NOT exported from `index.ts` — internal to the RemoteWrite binding.
21 */
22
23/** One sample of a series being remote-written; timestamp in epoch millis. */
24export interface EncodableSample {
25 value: number;
26 timestamp: number;
27}
28
29/** One fully-resolved time series: sorted-ready labels plus samples. */
30export interface EncodableSeries {
31 /** Complete label set INCLUDING `__name__`. */
32 labels: Record<string, string>;
33 samples: EncodableSample[];
34}
35
36const textEncoder = new TextEncoder();
37
38const pushVarint = (out: number[], value: number | bigint) => {
39 let v = BigInt(value);
40 if (v < 0n) {
41 // int64 negative values are 10-byte two's-complement varints.
42 v &= 0xffffffffffffffffn;
43 }
44 while (v >= 0x80n) {
45 out.push(Number(v & 0x7fn) | 0x80);
46 v >>= 7n;
47 }
48 out.push(Number(v));
49};
50
51const pushLengthDelimited = (
52 out: number[],
53 fieldNumber: number,
54 payload: ArrayLike<number>,
55) => {
56 out.push((fieldNumber << 3) | 2);
57 pushVarint(out, payload.length);
58 for (let i = 0; i < payload.length; i++) {
59 out.push(payload[i]!);
60 }
61};
62
63const encodeLabel = (name: string, value: string): number[] => {
64 const out: number[] = [];
65 pushLengthDelimited(out, 1, textEncoder.encode(name));
66 pushLengthDelimited(out, 2, textEncoder.encode(value));
67 return out;
68};
69
70const float64Buffer = new DataView(new ArrayBuffer(8));
71
72const encodeSample = (sample: EncodableSample): number[] => {
73 const out: number[] = [];
74 // field 1: double value (wire type 1, fixed64, little-endian)
75 out.push(0x09);
76 float64Buffer.setFloat64(0, sample.value, true);
77 for (let i = 0; i < 8; i++) {
78 out.push(float64Buffer.getUint8(i));
79 }
80 // field 2: int64 timestamp (wire type 0, varint)
81 out.push(0x10);
82 pushVarint(out, Math.round(sample.timestamp));
83 return out;
84};
85
86const encodeTimeSeries = (series: EncodableSeries): number[] => {
87 const out: number[] = [];
88 // Remote-write requires labels sorted by name.
89 const names = Object.keys(series.labels).sort();
90 for (const name of names) {
91 pushLengthDelimited(out, 1, encodeLabel(name, series.labels[name]!));
92 }
93 for (const sample of series.samples) {
94 pushLengthDelimited(out, 2, encodeSample(sample));
95 }
96 return out;
97};
98
99/** Encode a `prometheus.WriteRequest` protobuf message. */
100export const encodeWriteRequest = (
101 timeseries: readonly EncodableSeries[],
102): Uint8Array => {
103 const out: number[] = [];
104 for (const series of timeseries) {
105 pushLengthDelimited(out, 1, encodeTimeSeries(series));
106 }
107 return Uint8Array.from(out);
108};
109
110/**
111 * Snappy-compress `input` using the raw block format with literal-only
112 * elements: `varint(len(input))` followed by literal chunks of at most 64 KiB
113 * each. Valid snappy per the format spec (compression is optional).
114 */
115export const snappyCompress = (input: Uint8Array): Uint8Array => {
116 const header: number[] = [];
117 pushVarint(header, input.length);
118
119 const chunkSize = 65536;
120 const chunkCount = Math.ceil(input.length / chunkSize);
121 // Each chunk's literal tag: 1 byte for len <= 60, else 1 tag + 2 length bytes.
122 let totalLength = header.length;
123 for (let i = 0; i < chunkCount; i++) {
124 const len = Math.min(chunkSize, input.length - i * chunkSize);
125 totalLength += (len <= 60 ? 1 : 3) + len;
126 }
127
128 const out = new Uint8Array(totalLength);
129 out.set(header, 0);
130 let offset = header.length;
131 for (let i = 0; i < chunkCount; i++) {
132 const start = i * chunkSize;
133 const len = Math.min(chunkSize, input.length - start);
134 if (len <= 60) {
135 out[offset++] = (len - 1) << 2;
136 } else {
137 // tag 61: literal with 2-byte little-endian (length - 1).
138 out[offset++] = 61 << 2;
139 out[offset++] = (len - 1) & 0xff;
140 out[offset++] = ((len - 1) >> 8) & 0xff;
141 }
142 out.set(input.subarray(start, start + len), offset);
143 offset += len;
144 }
145 return out;
146};