Skip to content

Commit 2cd4e5b

Browse files
authored
Merge pull request #22 from nuonco/cb/implement-remote-write
Implement Prometheus Remote Write format Fixes `400: decompress snappy: snappy: corrupt input` by implementing proper Prometheus Remote Write protocol. Problem: Grafana's Prometheus endpoint requires Remote Write format (Protobuf + Snappy compression), but we were sending OpenMetrics text format. Solution: - Manually implement Protobuf encoding following Prometheus Remote Write spec - Add Snappy compression - Send with correct headers (`Content-Encoding: snappy`, `Content-Type: application/x-protobuf`) Changes: - Add `python-snappy` and `protobuf` dependencies - Remove `prometheus-client` (doesn't support Remote Write, only exposition format) - Implement manual protobuf encoding for Remote Write protocol - Compress payloads with Snappy before sending
2 parents fdee77b + 3177665 commit 2cd4e5b

2 files changed

Lines changed: 146 additions & 24 deletions

File tree

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ uvicorn==0.24.0
33
httpx==0.25.1
44
pydantic==2.5.0
55
python-dotenv==1.0.0
6-
prometheus-client==0.19.0
6+
python-snappy==0.7.1
7+
protobuf==4.25.1

src/integrations/grafana/client.py

Lines changed: 144 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
import logging
44
import time
55
import base64
6+
import snappy
67
from typing import List, Dict, Any
78
from fastapi import HTTPException
8-
from prometheus_client import CollectorRegistry, Gauge
9-
from prometheus_client.exposition import basic_auth_handler
10-
from prometheus_client.openmetrics.exposition import generate_latest
119
import httpx
1210

11+
from prometheus_client.core import CollectorRegistry, Gauge
12+
from prometheus_client.exposition import generate_latest
13+
1314
from ...config import Config
1415
from ...models import NuonWebhook
1516

@@ -133,6 +134,139 @@ def _build_labels(self, webhook: NuonWebhook) -> Dict[str, str]:
133134

134135
return labels
135136

137+
# PROTOBUF MESSAGE BUILDER
138+
def _build_remote_write_request(self, metrics: List[Dict[str, Any]], labels: Dict[str, str], timestamp: int) -> bytes:
139+
"""
140+
Build Prometheus Remote Write protobuf message.
141+
142+
Uses simplified protobuf construction following Prometheus spec.
143+
144+
Args:
145+
metrics: List of metric dictionaries
146+
labels: Label key-value pairs
147+
timestamp: Unix timestamp (seconds)
148+
149+
Returns:
150+
Protobuf-encoded bytes
151+
"""
152+
from google.protobuf import text_format
153+
from google.protobuf.message import Message
154+
155+
# Build time series for each metric
156+
timeseries = []
157+
timestamp_ms = timestamp * 1000 # Convert to milliseconds
158+
159+
for metric in metrics:
160+
# Build labels for this metric (metric name + common labels)
161+
metric_labels = [{"name": "__name__", "value": metric["name"]}]
162+
for k, v in labels.items():
163+
metric_labels.append({"name": k, "value": v})
164+
165+
# Build sample (timestamp + value)
166+
sample = {"value": metric["value"], "timestamp": timestamp_ms}
167+
168+
# Build time series
169+
timeseries.append({
170+
"labels": metric_labels,
171+
"samples": [sample]
172+
})
173+
174+
# Build write request
175+
write_request = {"timeseries": timeseries}
176+
177+
# Convert to protobuf bytes using manual encoding
178+
# This is a simplified protobuf encoding for Remote Write format
179+
return self._encode_protobuf(write_request)
180+
181+
def _encode_protobuf(self, data: dict) -> bytes:
182+
"""
183+
Manually encode data to Prometheus Remote Write protobuf format.
184+
185+
This implements the minimal protobuf encoding needed for Remote Write.
186+
187+
Args:
188+
data: Dictionary with timeseries data
189+
190+
Returns:
191+
Protobuf-encoded bytes
192+
"""
193+
# For now, use a simple encoding approach
194+
# We'll encode the timeseries data following the Prometheus protobuf schema
195+
196+
output = bytearray()
197+
198+
for ts in data["timeseries"]:
199+
# Encode each timeseries
200+
ts_bytes = self._encode_timeseries(ts)
201+
# Field 1 is repeated TimeSeries (wire type 2 = length-delimited)
202+
output.append(0x0a) # Field 1, wire type 2
203+
output.extend(self._encode_varint(len(ts_bytes)))
204+
output.extend(ts_bytes)
205+
206+
return bytes(output)
207+
208+
def _encode_timeseries(self, ts: dict) -> bytes:
209+
"""Encode a single timeseries to protobuf."""
210+
output = bytearray()
211+
212+
# Encode labels (field 1)
213+
for label in ts["labels"]:
214+
label_bytes = self._encode_label(label)
215+
output.append(0x0a) # Field 1, wire type 2
216+
output.extend(self._encode_varint(len(label_bytes)))
217+
output.extend(label_bytes)
218+
219+
# Encode samples (field 2)
220+
for sample in ts["samples"]:
221+
sample_bytes = self._encode_sample(sample)
222+
output.append(0x12) # Field 2, wire type 2
223+
output.extend(self._encode_varint(len(sample_bytes)))
224+
output.extend(sample_bytes)
225+
226+
return bytes(output)
227+
228+
def _encode_label(self, label: dict) -> bytes:
229+
"""Encode a label to protobuf."""
230+
output = bytearray()
231+
232+
# Name (field 1, string)
233+
name_bytes = label["name"].encode("utf-8")
234+
output.append(0x0a) # Field 1, wire type 2
235+
output.extend(self._encode_varint(len(name_bytes)))
236+
output.extend(name_bytes)
237+
238+
# Value (field 2, string)
239+
value_bytes = label["value"].encode("utf-8")
240+
output.append(0x12) # Field 2, wire type 2
241+
output.extend(self._encode_varint(len(value_bytes)))
242+
output.extend(value_bytes)
243+
244+
return bytes(output)
245+
246+
def _encode_sample(self, sample: dict) -> bytes:
247+
"""Encode a sample to protobuf."""
248+
output = bytearray()
249+
250+
# Value (field 1, double)
251+
import struct
252+
output.append(0x09) # Field 1, wire type 1 (64-bit)
253+
output.extend(struct.pack('<d', sample["value"]))
254+
255+
# Timestamp (field 2, int64)
256+
output.append(0x10) # Field 2, wire type 0 (varint)
257+
output.extend(self._encode_varint(sample["timestamp"]))
258+
259+
return bytes(output)
260+
261+
def _encode_varint(self, value: int) -> bytes:
262+
"""Encode an integer as a protobuf varint."""
263+
output = bytearray()
264+
while value > 127:
265+
output.append((value & 0x7f) | 0x80)
266+
value >>= 7
267+
output.append(value & 0x7f)
268+
return bytes(output)
269+
136270
# SEND TO GRAFANA
137271
async def _send_to_grafana(self, metrics: List[Dict[str, Any]], labels: Dict[str, str], timestamp: int) -> bool:
138272
"""
@@ -151,25 +285,11 @@ async def _send_to_grafana(self, metrics: List[Dict[str, Any]], labels: Dict[str
151285
Raises:
152286
HTTPException: If sending fails
153287
"""
154-
# Create a temporary registry for these metrics
155-
registry = CollectorRegistry()
156-
157-
# Get label names and values
158-
label_names = list(labels.keys())
159-
label_values = list(labels.values())
160-
161-
# Create and set each metric
162-
for metric in metrics:
163-
gauge = Gauge(
164-
metric["name"],
165-
f"Nuon webhook metric: {metric['name']}",
166-
labelnames=label_names,
167-
registry=registry
168-
)
169-
gauge.labels(*label_values).set(metric["value"])
288+
# Build protobuf message
289+
protobuf_data = self._build_remote_write_request(metrics, labels, timestamp)
170290

171-
# Generate Prometheus exposition format
172-
metrics_data = generate_latest(registry)
291+
# Compress with Snappy
292+
compressed_data = snappy.compress(protobuf_data)
173293

174294
# Build Basic Auth header (base64-encoded)
175295
auth_string = f"{self.instance_id}:{self.api_token}"
@@ -180,10 +300,11 @@ async def _send_to_grafana(self, metrics: List[Dict[str, Any]], labels: Dict[str
180300
async with httpx.AsyncClient() as client:
181301
response = await client.post(
182302
self.metrics_url,
183-
content=metrics_data,
303+
content=compressed_data,
184304
headers={
185305
"Authorization": f"Basic {auth_b64}",
186-
"Content-Type": "application/openmetrics-text",
306+
"Content-Encoding": "snappy",
307+
"Content-Type": "application/x-protobuf",
187308
"X-Prometheus-Remote-Write-Version": "0.1.0"
188309
},
189310
timeout=10.0

0 commit comments

Comments
 (0)