fluxmeter 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluxmeter/__init__.py +8 -0
- fluxmeter/client.py +398 -0
- fluxmeter/event.py +85 -0
- fluxmeter/streaming.py +157 -0
- fluxmeter/wal.py +174 -0
- fluxmeter-1.0.0.dist-info/METADATA +133 -0
- fluxmeter-1.0.0.dist-info/RECORD +8 -0
- fluxmeter-1.0.0.dist-info/WHEEL +4 -0
fluxmeter/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""FluxMeter — streaming metering SDK for AI token billing."""
|
|
2
|
+
|
|
3
|
+
from fluxmeter.client import FluxMeter
|
|
4
|
+
from fluxmeter.event import TokenEvent
|
|
5
|
+
from fluxmeter.streaming import StreamingWrapper
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
__all__ = ["FluxMeter", "TokenEvent", "StreamingWrapper"]
|
fluxmeter/client.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""FluxMeter client — sends token usage events to Kafka."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import atexit
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from confluent_kafka import Producer
|
|
13
|
+
|
|
14
|
+
from fluxmeter.event import TokenEvent
|
|
15
|
+
from fluxmeter.streaming import StreamingWrapper
|
|
16
|
+
from fluxmeter.wal import WriteAheadLog
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FluxMeter:
|
|
22
|
+
"""Main FluxMeter client. Sends token events to Kafka for real-time aggregation.
|
|
23
|
+
|
|
24
|
+
Events are persisted to a local WAL (write-ahead log) BEFORE sending to Kafka.
|
|
25
|
+
If Kafka is unavailable, events accumulate on disk and flush when it recovers.
|
|
26
|
+
This guarantees zero event loss regardless of Kafka availability.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
from fluxmeter import FluxMeter
|
|
30
|
+
|
|
31
|
+
meter = FluxMeter(kafka_brokers="localhost:9094")
|
|
32
|
+
meter.track(customer_id="cust_123", model_id="gpt-4o", input_tokens=500, output_tokens=150)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
kafka_brokers: str = "localhost:9094",
|
|
38
|
+
topic: str = "token-events",
|
|
39
|
+
environment: Optional[str] = None,
|
|
40
|
+
producer_config: Optional[dict] = None,
|
|
41
|
+
wal_enabled: bool = True,
|
|
42
|
+
wal_path: str = "~/.fluxmeter/wal",
|
|
43
|
+
):
|
|
44
|
+
self._topic = topic
|
|
45
|
+
self._environment = environment
|
|
46
|
+
self._delivery_errors = 0
|
|
47
|
+
self._events_sent = 0
|
|
48
|
+
self._wal_enabled = wal_enabled
|
|
49
|
+
|
|
50
|
+
config = {
|
|
51
|
+
"bootstrap.servers": kafka_brokers,
|
|
52
|
+
"linger.ms": 5,
|
|
53
|
+
"batch.num.messages": 10000,
|
|
54
|
+
"compression.type": "lz4",
|
|
55
|
+
"acks": "all", # Wait for all replicas (no data loss on broker crash)
|
|
56
|
+
}
|
|
57
|
+
if producer_config:
|
|
58
|
+
config.update(producer_config)
|
|
59
|
+
|
|
60
|
+
self._producer = Producer(config)
|
|
61
|
+
|
|
62
|
+
# Local WAL: events persisted to disk before Kafka send
|
|
63
|
+
if wal_enabled:
|
|
64
|
+
self._wal = WriteAheadLog(path=wal_path)
|
|
65
|
+
self._flush_thread = threading.Thread(target=self._wal_flush_loop, daemon=True)
|
|
66
|
+
self._flush_thread.start()
|
|
67
|
+
else:
|
|
68
|
+
self._wal = None
|
|
69
|
+
|
|
70
|
+
atexit.register(self.flush)
|
|
71
|
+
|
|
72
|
+
def track(
|
|
73
|
+
self,
|
|
74
|
+
customer_id: str,
|
|
75
|
+
model_id: str,
|
|
76
|
+
*,
|
|
77
|
+
provider: str = "openai",
|
|
78
|
+
input_tokens: int = 0,
|
|
79
|
+
output_tokens: int = 0,
|
|
80
|
+
cache_read_tokens: int = 0,
|
|
81
|
+
cache_write_tokens: int = 0,
|
|
82
|
+
reasoning_tokens: int = 0,
|
|
83
|
+
embedding_tokens: int = 0,
|
|
84
|
+
request_id: Optional[str] = None,
|
|
85
|
+
span_id: Optional[str] = None,
|
|
86
|
+
parent_span_id: Optional[str] = None,
|
|
87
|
+
session_id: Optional[str] = None,
|
|
88
|
+
latency_ms: int = 0,
|
|
89
|
+
environment: Optional[str] = None,
|
|
90
|
+
metadata: Optional[dict[str, str]] = None,
|
|
91
|
+
) -> TokenEvent:
|
|
92
|
+
"""Track a single LLM API call's token usage.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
customer_id: Your customer/tenant identifier.
|
|
96
|
+
model_id: Model name (e.g. "gpt-4o", "claude-sonnet-4").
|
|
97
|
+
provider: Provider name ("openai", "anthropic", "google").
|
|
98
|
+
input_tokens: Prompt/input token count.
|
|
99
|
+
output_tokens: Completion/output token count.
|
|
100
|
+
cache_read_tokens: Cached prompt tokens read.
|
|
101
|
+
cache_write_tokens: Tokens written to prompt cache.
|
|
102
|
+
reasoning_tokens: Internal reasoning tokens (o1/o3).
|
|
103
|
+
embedding_tokens: Embedding tokens.
|
|
104
|
+
request_id: Provider's request ID.
|
|
105
|
+
span_id: Observability span ID.
|
|
106
|
+
session_id: Conversation/session identifier.
|
|
107
|
+
latency_ms: Provider response time in milliseconds.
|
|
108
|
+
environment: Override instance-level environment.
|
|
109
|
+
metadata: Arbitrary key-value pairs.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The TokenEvent that was sent.
|
|
113
|
+
"""
|
|
114
|
+
event = TokenEvent(
|
|
115
|
+
customer_id=customer_id,
|
|
116
|
+
model_id=model_id,
|
|
117
|
+
provider=provider,
|
|
118
|
+
input_tokens=input_tokens,
|
|
119
|
+
output_tokens=output_tokens,
|
|
120
|
+
cache_read_tokens=cache_read_tokens,
|
|
121
|
+
cache_write_tokens=cache_write_tokens,
|
|
122
|
+
reasoning_tokens=reasoning_tokens,
|
|
123
|
+
embedding_tokens=embedding_tokens,
|
|
124
|
+
request_id=request_id,
|
|
125
|
+
span_id=span_id,
|
|
126
|
+
parent_span_id=parent_span_id,
|
|
127
|
+
session_id=session_id,
|
|
128
|
+
latency_ms=latency_ms,
|
|
129
|
+
environment=environment or self._environment,
|
|
130
|
+
metadata=metadata,
|
|
131
|
+
)
|
|
132
|
+
self._send(event)
|
|
133
|
+
return event
|
|
134
|
+
|
|
135
|
+
def track_openai(
|
|
136
|
+
self,
|
|
137
|
+
customer_id: str,
|
|
138
|
+
response,
|
|
139
|
+
*,
|
|
140
|
+
session_id: Optional[str] = None,
|
|
141
|
+
span_id: Optional[str] = None,
|
|
142
|
+
latency_ms: int = 0,
|
|
143
|
+
environment: Optional[str] = None,
|
|
144
|
+
) -> TokenEvent:
|
|
145
|
+
"""Track usage from an OpenAI ChatCompletion response object.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
customer_id: Your customer/tenant identifier.
|
|
149
|
+
response: OpenAI ChatCompletion response (or dict).
|
|
150
|
+
session_id: Optional conversation session ID.
|
|
151
|
+
span_id: Optional observability span ID.
|
|
152
|
+
latency_ms: Request latency in ms.
|
|
153
|
+
environment: Override instance-level environment.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The TokenEvent that was sent.
|
|
157
|
+
"""
|
|
158
|
+
# Handle both object and dict responses
|
|
159
|
+
if hasattr(response, "model"):
|
|
160
|
+
model = response.model
|
|
161
|
+
usage = response.usage
|
|
162
|
+
request_id = response.id
|
|
163
|
+
else:
|
|
164
|
+
model = response["model"]
|
|
165
|
+
usage = response["usage"]
|
|
166
|
+
request_id = response.get("id")
|
|
167
|
+
|
|
168
|
+
# Extract token counts from usage
|
|
169
|
+
if hasattr(usage, "prompt_tokens"):
|
|
170
|
+
input_tokens = usage.prompt_tokens or 0
|
|
171
|
+
output_tokens = usage.completion_tokens or 0
|
|
172
|
+
cache_read = getattr(usage, "prompt_tokens_details", None)
|
|
173
|
+
cache_read_tokens = (
|
|
174
|
+
getattr(cache_read, "cached_tokens", 0) if cache_read else 0
|
|
175
|
+
)
|
|
176
|
+
reasoning = getattr(usage, "completion_tokens_details", None)
|
|
177
|
+
reasoning_tokens = (
|
|
178
|
+
getattr(reasoning, "reasoning_tokens", 0) if reasoning else 0
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
input_tokens = usage.get("prompt_tokens", 0)
|
|
182
|
+
output_tokens = usage.get("completion_tokens", 0)
|
|
183
|
+
details = usage.get("prompt_tokens_details", {}) or {}
|
|
184
|
+
cache_read_tokens = details.get("cached_tokens", 0)
|
|
185
|
+
comp_details = usage.get("completion_tokens_details", {}) or {}
|
|
186
|
+
reasoning_tokens = comp_details.get("reasoning_tokens", 0)
|
|
187
|
+
|
|
188
|
+
return self.track(
|
|
189
|
+
customer_id=customer_id,
|
|
190
|
+
model_id=model,
|
|
191
|
+
provider="openai",
|
|
192
|
+
input_tokens=input_tokens,
|
|
193
|
+
output_tokens=output_tokens,
|
|
194
|
+
cache_read_tokens=cache_read_tokens,
|
|
195
|
+
reasoning_tokens=reasoning_tokens,
|
|
196
|
+
request_id=request_id,
|
|
197
|
+
span_id=span_id,
|
|
198
|
+
session_id=session_id,
|
|
199
|
+
latency_ms=latency_ms,
|
|
200
|
+
environment=environment,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def track_anthropic(
|
|
204
|
+
self,
|
|
205
|
+
customer_id: str,
|
|
206
|
+
response,
|
|
207
|
+
*,
|
|
208
|
+
session_id: Optional[str] = None,
|
|
209
|
+
span_id: Optional[str] = None,
|
|
210
|
+
latency_ms: int = 0,
|
|
211
|
+
environment: Optional[str] = None,
|
|
212
|
+
) -> TokenEvent:
|
|
213
|
+
"""Track usage from an Anthropic Message response object.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
customer_id: Your customer/tenant identifier.
|
|
217
|
+
response: Anthropic Message response (or dict).
|
|
218
|
+
session_id: Optional conversation session ID.
|
|
219
|
+
span_id: Optional observability span ID.
|
|
220
|
+
latency_ms: Request latency in ms.
|
|
221
|
+
environment: Override instance-level environment.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The TokenEvent that was sent.
|
|
225
|
+
"""
|
|
226
|
+
if hasattr(response, "model"):
|
|
227
|
+
model = response.model
|
|
228
|
+
usage = response.usage
|
|
229
|
+
request_id = response.id
|
|
230
|
+
else:
|
|
231
|
+
model = response["model"]
|
|
232
|
+
usage = response["usage"]
|
|
233
|
+
request_id = response.get("id")
|
|
234
|
+
|
|
235
|
+
if hasattr(usage, "input_tokens"):
|
|
236
|
+
input_tokens = usage.input_tokens or 0
|
|
237
|
+
output_tokens = usage.output_tokens or 0
|
|
238
|
+
cache_read_tokens = getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
239
|
+
cache_write_tokens = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
|
240
|
+
else:
|
|
241
|
+
input_tokens = usage.get("input_tokens", 0)
|
|
242
|
+
output_tokens = usage.get("output_tokens", 0)
|
|
243
|
+
cache_read_tokens = usage.get("cache_read_input_tokens", 0)
|
|
244
|
+
cache_write_tokens = usage.get("cache_creation_input_tokens", 0)
|
|
245
|
+
|
|
246
|
+
return self.track(
|
|
247
|
+
customer_id=customer_id,
|
|
248
|
+
model_id=model,
|
|
249
|
+
provider="anthropic",
|
|
250
|
+
input_tokens=input_tokens,
|
|
251
|
+
output_tokens=output_tokens,
|
|
252
|
+
cache_read_tokens=cache_read_tokens,
|
|
253
|
+
cache_write_tokens=cache_write_tokens,
|
|
254
|
+
request_id=request_id,
|
|
255
|
+
span_id=span_id,
|
|
256
|
+
session_id=session_id,
|
|
257
|
+
latency_ms=latency_ms,
|
|
258
|
+
environment=environment,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def wrap_stream(
|
|
262
|
+
self,
|
|
263
|
+
stream,
|
|
264
|
+
customer_id: str,
|
|
265
|
+
model_id: str,
|
|
266
|
+
*,
|
|
267
|
+
provider: str = "openai",
|
|
268
|
+
input_tokens: int = 0,
|
|
269
|
+
heartbeat_interval_sec: float = 2.0,
|
|
270
|
+
parent_span_id: Optional[str] = None,
|
|
271
|
+
session_id: Optional[str] = None,
|
|
272
|
+
environment: Optional[str] = None,
|
|
273
|
+
) -> StreamingWrapper:
|
|
274
|
+
"""Wrap a streaming LLM response for near-real-time usage tracking.
|
|
275
|
+
|
|
276
|
+
Emits heartbeat events every heartbeat_interval_sec during the stream,
|
|
277
|
+
then a final accurate event when the stream completes.
|
|
278
|
+
|
|
279
|
+
Usage:
|
|
280
|
+
stream = client.chat.completions.create(..., stream=True)
|
|
281
|
+
for chunk in meter.wrap_stream(stream, "cust_1", "gpt-4o"):
|
|
282
|
+
process(chunk)
|
|
283
|
+
# Final event emitted automatically
|
|
284
|
+
"""
|
|
285
|
+
return StreamingWrapper(
|
|
286
|
+
stream=stream,
|
|
287
|
+
meter=self,
|
|
288
|
+
customer_id=customer_id,
|
|
289
|
+
model_id=model_id,
|
|
290
|
+
provider=provider,
|
|
291
|
+
input_tokens=input_tokens,
|
|
292
|
+
heartbeat_interval_sec=heartbeat_interval_sec,
|
|
293
|
+
parent_span_id=parent_span_id,
|
|
294
|
+
session_id=session_id,
|
|
295
|
+
environment=environment or self._environment,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _send(self, event: TokenEvent) -> None:
|
|
299
|
+
"""Persist event to WAL, then send to Kafka. Zero data loss."""
|
|
300
|
+
event_dict = event.to_dict()
|
|
301
|
+
|
|
302
|
+
if self._wal:
|
|
303
|
+
self._wal.append(event_dict)
|
|
304
|
+
return # WAL flush thread is the sole Kafka sender (no duplicate replay)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
value = json.dumps(event_dict, separators=(",", ":")).encode("utf-8")
|
|
308
|
+
self._producer.produce(
|
|
309
|
+
topic=self._topic,
|
|
310
|
+
key=event.customer_id.encode("utf-8"),
|
|
311
|
+
value=value,
|
|
312
|
+
on_delivery=self._on_delivery,
|
|
313
|
+
)
|
|
314
|
+
self._events_sent += 1
|
|
315
|
+
self._producer.poll(0)
|
|
316
|
+
except (BufferError, Exception) as e:
|
|
317
|
+
self._delivery_errors += 1
|
|
318
|
+
logger.debug("Kafka send failed: %s", e)
|
|
319
|
+
|
|
320
|
+
def _produce_event(self, evt: dict) -> bool:
|
|
321
|
+
"""Send one event to Kafka and wait for broker ack. Returns False on failure."""
|
|
322
|
+
value = json.dumps(evt, separators=(",", ":")).encode("utf-8")
|
|
323
|
+
customer_id = evt.get("customerId", "unknown")
|
|
324
|
+
for _ in range(2):
|
|
325
|
+
try:
|
|
326
|
+
self._producer.produce(
|
|
327
|
+
topic=self._topic,
|
|
328
|
+
key=customer_id.encode("utf-8"),
|
|
329
|
+
value=value,
|
|
330
|
+
on_delivery=self._on_delivery,
|
|
331
|
+
)
|
|
332
|
+
self._producer.flush(timeout=10)
|
|
333
|
+
self._events_sent += 1
|
|
334
|
+
return True
|
|
335
|
+
except BufferError:
|
|
336
|
+
self._producer.flush(timeout=10)
|
|
337
|
+
except Exception as e:
|
|
338
|
+
self._delivery_errors += 1
|
|
339
|
+
logger.debug("Kafka send failed: %s", e)
|
|
340
|
+
return False
|
|
341
|
+
self._delivery_errors += 1
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
def _flush_wal_once(self) -> bool:
|
|
345
|
+
"""Send at most one pending WAL event across all files. Returns True if one was sent."""
|
|
346
|
+
if not self._wal:
|
|
347
|
+
return False
|
|
348
|
+
for f in self._wal.pending_files():
|
|
349
|
+
offset = self._wal.get_send_offset(f)
|
|
350
|
+
evt, new_offset = self._wal.read_next_event_from_offset(f, offset)
|
|
351
|
+
if evt is None:
|
|
352
|
+
if f != self._wal._current_file and self._wal.is_fully_sent(f):
|
|
353
|
+
self._wal.mark_flushed(f, 0)
|
|
354
|
+
continue
|
|
355
|
+
if not self._produce_event(evt):
|
|
356
|
+
return False
|
|
357
|
+
self._wal.advance_send_offset(f, new_offset)
|
|
358
|
+
if f != self._wal._current_file and self._wal.is_fully_sent(f):
|
|
359
|
+
self._wal.mark_flushed(f, 1)
|
|
360
|
+
return True
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
def _wal_flush_loop(self) -> None:
|
|
364
|
+
"""Background thread: sends pending WAL events to Kafka one at a time."""
|
|
365
|
+
while True:
|
|
366
|
+
time.sleep(1)
|
|
367
|
+
if not self._wal:
|
|
368
|
+
break
|
|
369
|
+
try:
|
|
370
|
+
while self._flush_wal_once():
|
|
371
|
+
pass
|
|
372
|
+
except Exception as e:
|
|
373
|
+
logger.debug("WAL flush error: %s", e)
|
|
374
|
+
|
|
375
|
+
def _on_delivery(self, err, msg):
|
|
376
|
+
if err:
|
|
377
|
+
self._delivery_errors += 1
|
|
378
|
+
logger.debug("FluxMeter delivery failed: %s", err)
|
|
379
|
+
|
|
380
|
+
def flush(self, timeout: float = 10.0) -> None:
|
|
381
|
+
"""Flush pending events. Drains WAL before closing."""
|
|
382
|
+
if self._wal:
|
|
383
|
+
deadline = time.time() + timeout
|
|
384
|
+
while time.time() < deadline and self._flush_wal_once():
|
|
385
|
+
pass
|
|
386
|
+
self._producer.flush(timeout=timeout)
|
|
387
|
+
if self._wal:
|
|
388
|
+
self._wal.close()
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def events_sent(self) -> int:
|
|
392
|
+
"""Total events sent (including buffered)."""
|
|
393
|
+
return self._events_sent
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def delivery_errors(self) -> int:
|
|
397
|
+
"""Total delivery failures."""
|
|
398
|
+
return self._delivery_errors
|
fluxmeter/event.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Token usage event model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field, asdict
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class TokenEvent:
|
|
13
|
+
"""Represents one LLM API call's token usage.
|
|
14
|
+
|
|
15
|
+
Supports OpenAI, Anthropic, Google, and custom providers.
|
|
16
|
+
All token fields are optional — set what's available from your provider response.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
customer_id: str
|
|
20
|
+
model_id: str
|
|
21
|
+
provider: str = "openai"
|
|
22
|
+
|
|
23
|
+
# Token counts
|
|
24
|
+
input_tokens: int = 0
|
|
25
|
+
output_tokens: int = 0
|
|
26
|
+
cache_read_tokens: int = 0
|
|
27
|
+
cache_write_tokens: int = 0
|
|
28
|
+
reasoning_tokens: int = 0
|
|
29
|
+
embedding_tokens: int = 0
|
|
30
|
+
|
|
31
|
+
# Identity & tracing
|
|
32
|
+
event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
33
|
+
request_id: Optional[str] = None
|
|
34
|
+
span_id: Optional[str] = None
|
|
35
|
+
parent_span_id: Optional[str] = None # Links child LLM calls to parent agent run
|
|
36
|
+
session_id: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
# Timing
|
|
39
|
+
timestamp: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
40
|
+
latency_ms: int = 0
|
|
41
|
+
|
|
42
|
+
# Context
|
|
43
|
+
environment: Optional[str] = None
|
|
44
|
+
metadata: Optional[dict[str, str]] = None
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
"""Serialize to dict with camelCase keys (matches Java consumer)."""
|
|
48
|
+
d = {
|
|
49
|
+
"eventId": self.event_id,
|
|
50
|
+
"customerId": self.customer_id,
|
|
51
|
+
"provider": self.provider,
|
|
52
|
+
"modelId": self.model_id,
|
|
53
|
+
"inputTokens": self.input_tokens,
|
|
54
|
+
"outputTokens": self.output_tokens,
|
|
55
|
+
"cacheReadTokens": self.cache_read_tokens,
|
|
56
|
+
"cacheWriteTokens": self.cache_write_tokens,
|
|
57
|
+
"reasoningTokens": self.reasoning_tokens,
|
|
58
|
+
"embeddingTokens": self.embedding_tokens,
|
|
59
|
+
"timestamp": self.timestamp,
|
|
60
|
+
"latencyMs": self.latency_ms,
|
|
61
|
+
}
|
|
62
|
+
if self.request_id:
|
|
63
|
+
d["requestId"] = self.request_id
|
|
64
|
+
if self.span_id:
|
|
65
|
+
d["spanId"] = self.span_id
|
|
66
|
+
if self.parent_span_id:
|
|
67
|
+
d["parentSpanId"] = self.parent_span_id
|
|
68
|
+
if self.session_id:
|
|
69
|
+
d["sessionId"] = self.session_id
|
|
70
|
+
if self.environment:
|
|
71
|
+
d["environment"] = self.environment
|
|
72
|
+
if self.metadata:
|
|
73
|
+
d["metadata"] = self.metadata
|
|
74
|
+
return d
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def total_tokens(self) -> int:
|
|
78
|
+
return (
|
|
79
|
+
self.input_tokens
|
|
80
|
+
+ self.output_tokens
|
|
81
|
+
+ self.cache_read_tokens
|
|
82
|
+
+ self.cache_write_tokens
|
|
83
|
+
+ self.reasoning_tokens
|
|
84
|
+
+ self.embedding_tokens
|
|
85
|
+
)
|
fluxmeter/streaming.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Streaming response wrapper for FluxMeter.
|
|
2
|
+
|
|
3
|
+
Wraps OpenAI/Anthropic streaming responses to emit partial usage events
|
|
4
|
+
during the stream (heartbeat every N chunks or every interval_sec).
|
|
5
|
+
Provides near-real-time visibility into long-running LLM calls.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from fluxmeter import FluxMeter
|
|
9
|
+
|
|
10
|
+
meter = FluxMeter(kafka_brokers="localhost:9094")
|
|
11
|
+
|
|
12
|
+
# OpenAI streaming
|
|
13
|
+
stream = client.chat.completions.create(model="gpt-4o", messages=[...], stream=True)
|
|
14
|
+
for chunk in meter.wrap_stream(stream, customer_id="cust_1", model_id="gpt-4o"):
|
|
15
|
+
process(chunk)
|
|
16
|
+
# Final usage event emitted automatically on stream end
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from typing import Iterator, Optional, Any
|
|
23
|
+
|
|
24
|
+
from fluxmeter.event import TokenEvent
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StreamingWrapper:
|
|
28
|
+
"""Wraps a streaming LLM response iterator with usage tracking.
|
|
29
|
+
|
|
30
|
+
Counts output tokens (approximated from chunks) and emits partial
|
|
31
|
+
usage events at regular intervals. Emits a final event on stream end.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
stream: Iterator[Any],
|
|
37
|
+
meter, # FluxMeter instance
|
|
38
|
+
customer_id: str,
|
|
39
|
+
model_id: str,
|
|
40
|
+
provider: str = "openai",
|
|
41
|
+
input_tokens: int = 0,
|
|
42
|
+
heartbeat_interval_sec: float = 2.0,
|
|
43
|
+
parent_span_id: Optional[str] = None,
|
|
44
|
+
session_id: Optional[str] = None,
|
|
45
|
+
environment: Optional[str] = None,
|
|
46
|
+
):
|
|
47
|
+
self._stream = stream
|
|
48
|
+
self._meter = meter
|
|
49
|
+
self._customer_id = customer_id
|
|
50
|
+
self._model_id = model_id
|
|
51
|
+
self._provider = provider
|
|
52
|
+
self._input_tokens = input_tokens
|
|
53
|
+
self._heartbeat_interval = heartbeat_interval_sec
|
|
54
|
+
self._parent_span_id = parent_span_id
|
|
55
|
+
self._session_id = session_id
|
|
56
|
+
self._environment = environment
|
|
57
|
+
|
|
58
|
+
self._output_chunks = 0
|
|
59
|
+
self._estimated_output_tokens = 0
|
|
60
|
+
self._last_emitted_output_tokens = 0
|
|
61
|
+
self._last_heartbeat = time.time()
|
|
62
|
+
self._start_time = time.time()
|
|
63
|
+
self._finished = False
|
|
64
|
+
self._request_id: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
def __iter__(self):
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __next__(self):
|
|
70
|
+
try:
|
|
71
|
+
chunk = next(self._stream)
|
|
72
|
+
self._process_chunk(chunk)
|
|
73
|
+
|
|
74
|
+
# Emit heartbeat if interval elapsed
|
|
75
|
+
now = time.time()
|
|
76
|
+
if now - self._last_heartbeat >= self._heartbeat_interval:
|
|
77
|
+
self._emit_heartbeat()
|
|
78
|
+
self._last_heartbeat = now
|
|
79
|
+
|
|
80
|
+
return chunk
|
|
81
|
+
except StopIteration:
|
|
82
|
+
self._emit_final()
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
def _process_chunk(self, chunk) -> None:
|
|
86
|
+
"""Extract token info from a streaming chunk."""
|
|
87
|
+
self._output_chunks += 1
|
|
88
|
+
|
|
89
|
+
# OpenAI: chunk.choices[0].delta.content
|
|
90
|
+
if hasattr(chunk, "choices") and chunk.choices:
|
|
91
|
+
delta = getattr(chunk.choices[0], "delta", None)
|
|
92
|
+
if delta and getattr(delta, "content", None):
|
|
93
|
+
# Approximate: ~0.75 tokens per character for English
|
|
94
|
+
self._estimated_output_tokens += max(1, len(delta.content) // 4)
|
|
95
|
+
if not self._request_id and hasattr(chunk, "id"):
|
|
96
|
+
self._request_id = chunk.id
|
|
97
|
+
|
|
98
|
+
# Anthropic: chunk.type == "content_block_delta", chunk.delta.text
|
|
99
|
+
elif hasattr(chunk, "type") and chunk.type == "content_block_delta":
|
|
100
|
+
text = getattr(getattr(chunk, "delta", None), "text", "")
|
|
101
|
+
if text:
|
|
102
|
+
self._estimated_output_tokens += max(1, len(text) // 4)
|
|
103
|
+
|
|
104
|
+
# OpenAI final chunk with usage
|
|
105
|
+
if hasattr(chunk, "usage") and chunk.usage:
|
|
106
|
+
usage = chunk.usage
|
|
107
|
+
if hasattr(usage, "completion_tokens") and usage.completion_tokens:
|
|
108
|
+
self._estimated_output_tokens = usage.completion_tokens
|
|
109
|
+
if hasattr(usage, "prompt_tokens") and usage.prompt_tokens:
|
|
110
|
+
self._input_tokens = usage.prompt_tokens
|
|
111
|
+
|
|
112
|
+
def _emit_heartbeat(self) -> None:
|
|
113
|
+
"""Emit a partial usage event (heartbeat) during streaming."""
|
|
114
|
+
delta = self._estimated_output_tokens - self._last_emitted_output_tokens
|
|
115
|
+
if delta <= 0:
|
|
116
|
+
return
|
|
117
|
+
self._last_emitted_output_tokens = self._estimated_output_tokens
|
|
118
|
+
self._meter.track(
|
|
119
|
+
customer_id=self._customer_id,
|
|
120
|
+
model_id=self._model_id,
|
|
121
|
+
provider=self._provider,
|
|
122
|
+
input_tokens=0, # Only count input once in final event
|
|
123
|
+
output_tokens=delta,
|
|
124
|
+
parent_span_id=self._parent_span_id,
|
|
125
|
+
session_id=self._session_id,
|
|
126
|
+
environment=self._environment,
|
|
127
|
+
metadata={"_heartbeat": "true", "_chunks": str(self._output_chunks)},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _emit_final(self) -> None:
|
|
131
|
+
"""Emit the final usage event with accurate totals on stream end."""
|
|
132
|
+
if self._finished:
|
|
133
|
+
return
|
|
134
|
+
self._finished = True
|
|
135
|
+
|
|
136
|
+
latency_ms = int((time.time() - self._start_time) * 1000)
|
|
137
|
+
self._meter.track(
|
|
138
|
+
customer_id=self._customer_id,
|
|
139
|
+
model_id=self._model_id,
|
|
140
|
+
provider=self._provider,
|
|
141
|
+
input_tokens=self._input_tokens,
|
|
142
|
+
output_tokens=self._estimated_output_tokens,
|
|
143
|
+
request_id=self._request_id,
|
|
144
|
+
parent_span_id=self._parent_span_id,
|
|
145
|
+
session_id=self._session_id,
|
|
146
|
+
latency_ms=latency_ms,
|
|
147
|
+
environment=self._environment,
|
|
148
|
+
metadata={"_stream_chunks": str(self._output_chunks)},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def estimated_output_tokens(self) -> int:
|
|
153
|
+
return self._estimated_output_tokens
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def elapsed_ms(self) -> int:
|
|
157
|
+
return int((time.time() - self._start_time) * 1000)
|
fluxmeter/wal.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Write-Ahead Log for FluxMeter SDK.
|
|
2
|
+
|
|
3
|
+
Events are persisted to a local append-only file BEFORE sending to Kafka.
|
|
4
|
+
If Kafka is unavailable, events accumulate on disk and flush when it recovers.
|
|
5
|
+
This guarantees no event loss regardless of Kafka availability.
|
|
6
|
+
|
|
7
|
+
File format: one JSON object per line (newline-delimited JSON / NDJSON).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WriteAheadLog:
|
|
24
|
+
"""Append-only local event buffer with background flush to Kafka."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
path: str = "~/.fluxmeter/wal",
|
|
29
|
+
max_file_size_mb: int = 100,
|
|
30
|
+
flush_interval_sec: float = 1.0,
|
|
31
|
+
):
|
|
32
|
+
self._dir = Path(os.path.expanduser(path))
|
|
33
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
self._max_file_size = max_file_size_mb * 1024 * 1024
|
|
35
|
+
self._flush_interval = flush_interval_sec
|
|
36
|
+
|
|
37
|
+
self._current_file: Optional[Path] = None
|
|
38
|
+
self._file_handle = None
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
self._pending_count = 0
|
|
41
|
+
self._flushed_count = 0
|
|
42
|
+
# Byte offset successfully sent to Kafka per file (avoids duplicate replay)
|
|
43
|
+
self._send_offsets: dict[str, int] = {}
|
|
44
|
+
|
|
45
|
+
self._rotate_if_needed()
|
|
46
|
+
|
|
47
|
+
def append(self, event_dict: dict) -> None:
|
|
48
|
+
"""Append event to WAL. Returns immediately. Thread-safe.
|
|
49
|
+
Batch fsync: every 100 events or 500ms, whichever comes first."""
|
|
50
|
+
line = json.dumps(event_dict, separators=(",", ":")) + "\n"
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._rotate_if_needed()
|
|
53
|
+
self._file_handle.write(line)
|
|
54
|
+
self._file_handle.flush()
|
|
55
|
+
self._pending_count += 1
|
|
56
|
+
if self._pending_count % 100 == 0:
|
|
57
|
+
os.fsync(self._file_handle.fileno())
|
|
58
|
+
|
|
59
|
+
def pending_files(self) -> list[Path]:
|
|
60
|
+
"""List WAL files that may have unsent events (oldest first)."""
|
|
61
|
+
files = sorted(self._dir.glob("wal-*.jsonl"))
|
|
62
|
+
return files
|
|
63
|
+
|
|
64
|
+
def get_send_offset(self, file_path: Path) -> int:
|
|
65
|
+
"""Return byte offset of last successfully sent event in this file."""
|
|
66
|
+
return self._send_offsets.get(str(file_path), 0)
|
|
67
|
+
|
|
68
|
+
def advance_send_offset(self, file_path: Path, new_offset: int) -> None:
|
|
69
|
+
"""Record how many bytes have been successfully sent from file_path."""
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._send_offsets[str(file_path)] = new_offset
|
|
72
|
+
|
|
73
|
+
def read_next_event_from_offset(
|
|
74
|
+
self, file_path: Path, byte_offset: int
|
|
75
|
+
) -> tuple[dict | None, int]:
|
|
76
|
+
"""Read at most one event from byte_offset. Returns (event, new_offset)."""
|
|
77
|
+
try:
|
|
78
|
+
with open(file_path, "r") as f:
|
|
79
|
+
f.seek(byte_offset)
|
|
80
|
+
line = f.readline()
|
|
81
|
+
if not line:
|
|
82
|
+
return None, byte_offset
|
|
83
|
+
stripped = line.strip()
|
|
84
|
+
if not stripped:
|
|
85
|
+
return None, f.tell()
|
|
86
|
+
try:
|
|
87
|
+
return json.loads(stripped), f.tell()
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return None, f.tell()
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return None, byte_offset
|
|
92
|
+
|
|
93
|
+
def read_events_from_offset(self, file_path: Path, byte_offset: int) -> tuple[list[dict], int]:
|
|
94
|
+
"""Read events starting at byte_offset. Returns (events, new_byte_offset)."""
|
|
95
|
+
events: list[dict] = []
|
|
96
|
+
new_offset = byte_offset
|
|
97
|
+
try:
|
|
98
|
+
with open(file_path, "r") as f:
|
|
99
|
+
f.seek(byte_offset)
|
|
100
|
+
while True:
|
|
101
|
+
line_start = f.tell()
|
|
102
|
+
line = f.readline()
|
|
103
|
+
if not line:
|
|
104
|
+
break
|
|
105
|
+
stripped = line.strip()
|
|
106
|
+
if stripped:
|
|
107
|
+
try:
|
|
108
|
+
events.append(json.loads(stripped))
|
|
109
|
+
new_offset = f.tell()
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
new_offset = f.tell()
|
|
112
|
+
continue
|
|
113
|
+
else:
|
|
114
|
+
new_offset = line_start + len(line)
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
pass
|
|
117
|
+
return events, new_offset
|
|
118
|
+
|
|
119
|
+
def is_fully_sent(self, file_path: Path) -> bool:
|
|
120
|
+
"""True if all bytes in file have been sent to Kafka."""
|
|
121
|
+
try:
|
|
122
|
+
size = file_path.stat().st_size
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
return True
|
|
125
|
+
return self.get_send_offset(file_path) >= size and size > 0
|
|
126
|
+
|
|
127
|
+
def mark_flushed(self, file_path: Path, count: int) -> None:
|
|
128
|
+
"""Mark a WAL file as fully flushed to Kafka. Deletes it."""
|
|
129
|
+
with self._lock:
|
|
130
|
+
if file_path == self._current_file:
|
|
131
|
+
return
|
|
132
|
+
key = str(file_path)
|
|
133
|
+
self._send_offsets.pop(key, None)
|
|
134
|
+
try:
|
|
135
|
+
file_path.unlink()
|
|
136
|
+
self._flushed_count += count
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def read_events(self, file_path: Path) -> list[dict]:
|
|
141
|
+
"""Read all events from a WAL file."""
|
|
142
|
+
events, _ = self.read_events_from_offset(file_path, 0)
|
|
143
|
+
return events
|
|
144
|
+
|
|
145
|
+
def _rotate_if_needed(self) -> None:
|
|
146
|
+
"""Create a new WAL file if current is too large or doesn't exist."""
|
|
147
|
+
if self._file_handle and self._current_file:
|
|
148
|
+
try:
|
|
149
|
+
size = self._current_file.stat().st_size
|
|
150
|
+
if size < self._max_file_size:
|
|
151
|
+
return
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
if self._file_handle:
|
|
156
|
+
self._file_handle.close()
|
|
157
|
+
|
|
158
|
+
ts = int(time.time() * 1000)
|
|
159
|
+
self._current_file = self._dir / f"wal-{ts}.jsonl"
|
|
160
|
+
self._file_handle = open(self._current_file, "a")
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def pending_count(self) -> int:
|
|
164
|
+
return self._pending_count
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def flushed_count(self) -> int:
|
|
168
|
+
return self._flushed_count
|
|
169
|
+
|
|
170
|
+
def close(self) -> None:
|
|
171
|
+
with self._lock:
|
|
172
|
+
if self._file_handle:
|
|
173
|
+
self._file_handle.close()
|
|
174
|
+
self._file_handle = None
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fluxmeter
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for FluxMeter — streaming metering for AI token billing
|
|
5
|
+
Project-URL: Homepage, https://github.com/10kshuaizhang/fluxmeter
|
|
6
|
+
Project-URL: Repository, https://github.com/10kshuaizhang/fluxmeter
|
|
7
|
+
Author-email: FluxMeter <hello@fluxmeter.dev>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: ai,anthropic,billing,llm,metering,openai,streaming,tokens
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: confluent-kafka>=2.3.0
|
|
17
|
+
Provides-Extra: anthropic
|
|
18
|
+
Requires-Dist: anthropic>=0.20; extra == 'anthropic'
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
23
|
+
Provides-Extra: openai
|
|
24
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# FluxMeter Python SDK
|
|
28
|
+
|
|
29
|
+
Send AI token usage events to FluxMeter for real-time aggregation and billing.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install fluxmeter
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start (3 lines)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from fluxmeter import FluxMeter
|
|
41
|
+
|
|
42
|
+
meter = FluxMeter(kafka_brokers="localhost:9094")
|
|
43
|
+
meter.track("cust_123", "gpt-4o", input_tokens=500, output_tokens=150)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## OpenAI Integration
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import time
|
|
50
|
+
from openai import OpenAI
|
|
51
|
+
from fluxmeter import FluxMeter
|
|
52
|
+
|
|
53
|
+
client = OpenAI()
|
|
54
|
+
meter = FluxMeter(kafka_brokers="localhost:9094", environment="production")
|
|
55
|
+
|
|
56
|
+
start = time.time()
|
|
57
|
+
response = client.chat.completions.create(
|
|
58
|
+
model="gpt-4o",
|
|
59
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
60
|
+
)
|
|
61
|
+
latency = int((time.time() - start) * 1000)
|
|
62
|
+
|
|
63
|
+
# One line to meter the usage
|
|
64
|
+
meter.track_openai("cust_123", response, latency_ms=latency)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Anthropic Integration
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import anthropic
|
|
71
|
+
from fluxmeter import FluxMeter
|
|
72
|
+
|
|
73
|
+
client = anthropic.Anthropic()
|
|
74
|
+
meter = FluxMeter(kafka_brokers="localhost:9094")
|
|
75
|
+
|
|
76
|
+
response = client.messages.create(
|
|
77
|
+
model="claude-sonnet-4-20250514",
|
|
78
|
+
max_tokens=1024,
|
|
79
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
meter.track_anthropic("cust_123", response)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Manual Tracking (any provider)
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
meter.track(
|
|
89
|
+
customer_id="cust_123",
|
|
90
|
+
model_id="gemini-1.5-pro",
|
|
91
|
+
provider="google",
|
|
92
|
+
input_tokens=2000,
|
|
93
|
+
output_tokens=500,
|
|
94
|
+
request_id="req_abc123",
|
|
95
|
+
span_id="span_7f3a", # link to your tracing
|
|
96
|
+
session_id="sess_456", # group by conversation
|
|
97
|
+
latency_ms=890,
|
|
98
|
+
environment="production",
|
|
99
|
+
metadata={"feature": "code-review", "team": "platform"},
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
meter = FluxMeter(
|
|
107
|
+
kafka_brokers="kafka1:9092,kafka2:9092", # Kafka cluster
|
|
108
|
+
topic="token-events", # Topic name (default)
|
|
109
|
+
environment="production", # Applied to all events
|
|
110
|
+
producer_config={ # Extra Kafka producer config
|
|
111
|
+
"security.protocol": "SASL_SSL",
|
|
112
|
+
"sasl.mechanisms": "PLAIN",
|
|
113
|
+
"sasl.username": "...",
|
|
114
|
+
"sasl.password": "...",
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## How It Works
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
Your App → meter.track(...) → Kafka → Flink (real-time aggregation) → Redis
|
|
123
|
+
↓
|
|
124
|
+
Grafana / API
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Events are batched and compressed (lz4) before sending. The SDK flushes automatically on process exit.
|
|
128
|
+
|
|
129
|
+
## Requirements
|
|
130
|
+
|
|
131
|
+
- Python 3.9+
|
|
132
|
+
- `confluent-kafka` (librdkafka-based, high performance)
|
|
133
|
+
- FluxMeter infrastructure running (Kafka + Flink + Redis)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
fluxmeter/__init__.py,sha256=brlAjcY_1tJ8yzap1Qd_4nOmlvHHboVlmg2sbFwoRjI,274
|
|
2
|
+
fluxmeter/client.py,sha256=-7lOcv2llKovMqFHX5uCIBL2xcZQZoPfWzfM5gkfdeI,14329
|
|
3
|
+
fluxmeter/event.py,sha256=QJ7CWE3vfs7m5t-pNBefRmpNaisA0YS-l1rY-HVRLmM,2608
|
|
4
|
+
fluxmeter/streaming.py,sha256=Vyxo3bWl1Ac6r2Zj1zECa_M-oz1n9ZwXissele0RfDI,5699
|
|
5
|
+
fluxmeter/wal.py,sha256=4W_o--UJYXatVp6Rm0Pg_bCEBUq95-8jvKfr5X2YrPw,6228
|
|
6
|
+
fluxmeter-1.0.0.dist-info/METADATA,sha256=ciT7Bb8DMGqHsOzt_VxecOQ4UVh22d6D79Pr6GvEkq8,3735
|
|
7
|
+
fluxmeter-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
fluxmeter-1.0.0.dist-info/RECORD,,
|