| 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. */ |
| 24 | export interface EncodableSample { |
| 25 | value: number; |
| 26 | timestamp: number; |
| 27 | } |
| 28 | |
| 29 | /** One fully-resolved time series: sorted-ready labels plus samples. */ |
| 30 | export interface EncodableSeries { |
| 31 | /** Complete label set INCLUDING `__name__`. */ |
| 32 | labels: Record<string, string>; |
| 33 | samples: EncodableSample[]; |
| 34 | } |
| 35 | |
| 36 | const textEncoder = new TextEncoder(); |
| 37 | |
| 38 | const 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 | |
| 51 | const 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 | |
| 63 | const 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 | |
| 70 | const float64Buffer = new DataView(new ArrayBuffer(8)); |
| 71 | |
| 72 | const 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 | |
| 86 | const 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. */ |
| 100 | export 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 | */ |
| 115 | export 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 | }; |