temporal-flowrensic-python-sdk 0.6.63__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.
Files changed (54) hide show
  1. temporal_flowrensic/__init__.py +2 -0
  2. temporal_flowrensic/_calls.py +185 -0
  3. temporal_flowrensic/_ci.py +56 -0
  4. temporal_flowrensic/_deploy.py +65 -0
  5. temporal_flowrensic/_gen/flowrensic/v1/activity_metrics_pb2.py +76 -0
  6. temporal_flowrensic/_gen/flowrensic/v1/activity_metrics_pb2.pyi +301 -0
  7. temporal_flowrensic/_gen/flowrensic/v1/catalog_pb2.py +60 -0
  8. temporal_flowrensic/_gen/flowrensic/v1/catalog_pb2.pyi +127 -0
  9. temporal_flowrensic/_gen/flowrensic/v1/catch_up_pb2.py +42 -0
  10. temporal_flowrensic/_gen/flowrensic/v1/catch_up_pb2.pyi +71 -0
  11. temporal_flowrensic/_gen/flowrensic/v1/client_calls_pb2.py +49 -0
  12. temporal_flowrensic/_gen/flowrensic/v1/client_calls_pb2.pyi +90 -0
  13. temporal_flowrensic/_gen/flowrensic/v1/cost_pb2.py +52 -0
  14. temporal_flowrensic/_gen/flowrensic/v1/cost_pb2.pyi +107 -0
  15. temporal_flowrensic/_gen/flowrensic/v1/deploy_pb2.py +51 -0
  16. temporal_flowrensic/_gen/flowrensic/v1/deploy_pb2.pyi +92 -0
  17. temporal_flowrensic/_gen/flowrensic/v1/ephemeral_run_pb2.py +107 -0
  18. temporal_flowrensic/_gen/flowrensic/v1/ephemeral_run_pb2.pyi +344 -0
  19. temporal_flowrensic/_gen/flowrensic/v1/finding_pb2.py +76 -0
  20. temporal_flowrensic/_gen/flowrensic/v1/finding_pb2.pyi +371 -0
  21. temporal_flowrensic/_gen/flowrensic/v1/job_pb2.py +46 -0
  22. temporal_flowrensic/_gen/flowrensic/v1/job_pb2.pyi +51 -0
  23. temporal_flowrensic/_gen/flowrensic/v1/load_pb2.py +51 -0
  24. temporal_flowrensic/_gen/flowrensic/v1/load_pb2.pyi +118 -0
  25. temporal_flowrensic/_gen/flowrensic/v1/page_pb2.py +38 -0
  26. temporal_flowrensic/_gen/flowrensic/v1/page_pb2.pyi +23 -0
  27. temporal_flowrensic/_gen/flowrensic/v1/remediation_pb2.py +60 -0
  28. temporal_flowrensic/_gen/flowrensic/v1/remediation_pb2.pyi +157 -0
  29. temporal_flowrensic/_gen/flowrensic/v1/replay_pb2.py +84 -0
  30. temporal_flowrensic/_gen/flowrensic/v1/replay_pb2.pyi +309 -0
  31. temporal_flowrensic/_gen/flowrensic/v1/server_config_pb2.py +48 -0
  32. temporal_flowrensic/_gen/flowrensic/v1/server_config_pb2.pyi +97 -0
  33. temporal_flowrensic/_gen/flowrensic/v1/services_connect.py +3621 -0
  34. temporal_flowrensic/_gen/flowrensic/v1/services_pb2.py +60 -0
  35. temporal_flowrensic/_gen/flowrensic/v1/services_pb2.pyi +20 -0
  36. temporal_flowrensic/_gen/flowrensic/v1/stored_data_pb2.py +43 -0
  37. temporal_flowrensic/_gen/flowrensic/v1/stored_data_pb2.pyi +50 -0
  38. temporal_flowrensic/_gen/flowrensic/v1/workflow_history_pb2.py +47 -0
  39. temporal_flowrensic/_gen/flowrensic/v1/workflow_history_pb2.pyi +102 -0
  40. temporal_flowrensic/_gen/flowrensic/v1/workflow_types_pb2.py +55 -0
  41. temporal_flowrensic/_gen/flowrensic/v1/workflow_types_pb2.pyi +197 -0
  42. temporal_flowrensic/_names.py +42 -0
  43. temporal_flowrensic/_queries.py +61 -0
  44. temporal_flowrensic/_replay.py +378 -0
  45. temporal_flowrensic/_sink.py +574 -0
  46. temporal_flowrensic/_telemetry.py +559 -0
  47. temporal_flowrensic/activity.py +136 -0
  48. temporal_flowrensic/client.py +166 -0
  49. temporal_flowrensic/py.typed +0 -0
  50. temporal_flowrensic/workflow.py +103 -0
  51. temporal_flowrensic_python_sdk-0.6.63.dist-info/METADATA +74 -0
  52. temporal_flowrensic_python_sdk-0.6.63.dist-info/RECORD +54 -0
  53. temporal_flowrensic_python_sdk-0.6.63.dist-info/WHEEL +4 -0
  54. temporal_flowrensic_python_sdk-0.6.63.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,574 @@
1
+ """The record buffer every sender of the SDK uses: records wait in the worker's
2
+ memory and leave in batches on a thread of their own, so recording never waits
3
+ on Flowrensic."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import socket
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from collections import deque
13
+ from collections.abc import Iterable, Mapping
14
+ from dataclasses import dataclass
15
+ from typing import Any, Generic, Optional, TypeVar
16
+
17
+ from connectrpc.code import Code
18
+ from connectrpc.errors import ConnectError
19
+ from google.protobuf.message import Message
20
+
21
+ from temporal_flowrensic._gen.flowrensic.v1.activity_metrics_pb2 import (
22
+ ActivityAttempt,
23
+ IngestActivityEventsRequest,
24
+ WorkerBufferReport,
25
+ )
26
+ from temporal_flowrensic._gen.flowrensic.v1.services_connect import IngestServiceClientSync
27
+
28
+ __all__ = [
29
+ "AttemptSink",
30
+ "BufferTarget",
31
+ "RecordSink",
32
+ "SendId",
33
+ "SinkCounts",
34
+ "approx_size",
35
+ "create_attempt_sink",
36
+ "create_record_buffer",
37
+ ]
38
+
39
+ T = TypeVar("T")
40
+
41
+ CURRENT_ENV_HEADER = "Flowrensic-Current-Env"
42
+ ENV_ACCESS_KEY_HEADER = "Flowrensic-Env-Access-Key"
43
+ CURRENT_EPHEMERAL_RUN_HEADER = "Flowrensic-Current-Ephemeral-Run"
44
+
45
+ DEFAULT_BATCH_SIZE = 64
46
+ DEFAULT_FLUSH_INTERVAL = 1.0
47
+ DEFAULT_TIMEOUT_MS = 5000
48
+ DEFAULT_MAX_WAITING = 10_000
49
+ DEFAULT_MAX_BATCH_SIZE = 500
50
+ DEFAULT_MAX_WAITING_BYTES = 16 * 1024 * 1024
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class SinkCounts:
55
+ """What a sink lost and holds, over every env."""
56
+
57
+ dropped: int
58
+ """Records dropped since the sink was made: past a limit, in a send that was refused, or left at close."""
59
+ timed_out: int
60
+ """Sends given up at their timeout since the sink was made."""
61
+ waiting: int
62
+ """Records waiting to be sent now."""
63
+ waiting_bytes: int
64
+ """The size of the records waiting now, in bytes, as approx_size gives it."""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SendId:
69
+ """Which send a call is: the buffer that sends it and the number of the
70
+ send. A send sent again keeps its number, so Flowrensic stores it once."""
71
+
72
+ sender: str
73
+ send_number: int
74
+
75
+
76
+ class BufferTarget(Generic[T]):
77
+ """What a buffer needs from the sink that owns it."""
78
+
79
+ def send(self, records: list[T], timeout_ms: int, id: SendId) -> None:
80
+ """Sends one batch, and raises ConnectError when it fails."""
81
+ raise NotImplementedError
82
+
83
+ def env_of(self, record: T) -> str:
84
+ """The env a record is counted under."""
85
+ return ""
86
+
87
+ def report(self, reports: list[WorkerBufferReport], timeout_ms: int, id: SendId) -> None:
88
+ """Sends the counts. A target that does not send them raises
89
+ NotImplementedError, and the counts stay in the worker."""
90
+ raise NotImplementedError
91
+
92
+
93
+ def approx_size(value: Any, depth: int = 0) -> int:
94
+ """The size of a record in the worker's memory, worked out from its fields:
95
+ two bytes per character of each text, the length of each byte array, eight
96
+ bytes for each other value, and 32 bytes for the record and each message or
97
+ list in it. Every field counts, a field left at its default too."""
98
+ if isinstance(value, str):
99
+ return len(value) * 2
100
+ if isinstance(value, (bytes, bytearray)):
101
+ return 32 + len(value)
102
+ if isinstance(value, Message):
103
+ if depth >= 8:
104
+ return 32
105
+ total = 32
106
+ for field in value.DESCRIPTOR.fields:
107
+ total += approx_size(getattr(value, field.name), depth + 1)
108
+ return total
109
+ if isinstance(value, Mapping):
110
+ if depth >= 8:
111
+ return 32
112
+ return 32 + sum(approx_size(one, depth + 1) for one in value.values())
113
+ if isinstance(value, Iterable):
114
+ if depth >= 8:
115
+ return 32
116
+ return 32 + sum(approx_size(one, depth + 1) for one in value)
117
+ return 8
118
+
119
+
120
+ def _send_again(error: BaseException) -> bool:
121
+ """True when Flowrensic did not answer or answered try again later, so the batch is sent again."""
122
+ if not isinstance(error, ConnectError):
123
+ return False
124
+ return error.code in (Code.UNAVAILABLE, Code.DEADLINE_EXCEEDED, Code.RESOURCE_EXHAUSTED)
125
+
126
+
127
+ def _add(counts: dict[str, int], env: str, n: int) -> None:
128
+ now = counts.get(env, 0) + n
129
+ if now == 0:
130
+ counts.pop(env, None)
131
+ else:
132
+ counts[env] = now
133
+
134
+
135
+ @dataclass
136
+ class _Entry(Generic[T]):
137
+ record: T
138
+ size: int
139
+
140
+
141
+ @dataclass
142
+ class _Kept(Generic[T]):
143
+ entries: list[_Entry[T]]
144
+ id: SendId
145
+ size: int
146
+
147
+
148
+ @dataclass
149
+ class _KeptReport:
150
+ reports: list[WorkerBufferReport]
151
+ id: SendId
152
+ dropped: dict[str, int]
153
+ timed_out: dict[str, int]
154
+ waiting: dict[str, int]
155
+
156
+
157
+ class RecordSink(Generic[T]):
158
+ """Buffers records and sends them to a Flowrensic server in batches, on a
159
+ thread of its own. record never waits and never raises."""
160
+
161
+ def __init__(
162
+ self,
163
+ target: BufferTarget[T],
164
+ *,
165
+ batch_size: int = DEFAULT_BATCH_SIZE,
166
+ flush_interval: float = DEFAULT_FLUSH_INTERVAL,
167
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
168
+ max_waiting: int = DEFAULT_MAX_WAITING,
169
+ max_batch_size: int = DEFAULT_MAX_BATCH_SIZE,
170
+ max_waiting_bytes: int = DEFAULT_MAX_WAITING_BYTES,
171
+ ) -> None:
172
+ self._target = target
173
+ self._batch_size = batch_size
174
+ self._flush_interval = flush_interval
175
+ self._timeout_ms = timeout_ms
176
+ self._max_waiting = max_waiting
177
+ self._max_batch_size = max_batch_size
178
+ self._max_waiting_bytes = max_waiting_bytes
179
+
180
+ # Guards every field below. Held only for short work, never across a send.
181
+ self._lock = threading.Lock()
182
+ self._wake = threading.Condition(self._lock)
183
+ # One send or report at a time, by the thread or by flush.
184
+ self._sending = threading.Lock()
185
+
186
+ self._queue: deque[_Entry[T]] = deque()
187
+ self._queued_bytes = 0
188
+ self._kept: Optional[_Kept[T]] = None
189
+ self._kept_report: Optional[_KeptReport] = None
190
+ sender = str(uuid.uuid4())
191
+ self._records_sender = f"{sender}/records"
192
+ self._reports_sender = f"{sender}/reports"
193
+ self._send_number = 0
194
+ self._report_number = 0
195
+ self._waiting_by_env: dict[str, int] = {}
196
+ self._dropped: dict[str, int] = {}
197
+ self._timed_out: dict[str, int] = {}
198
+ self._reported_waiting: dict[str, int] = {}
199
+ self._dropped_total = 0
200
+ self._timed_out_total = 0
201
+ self._failing = False
202
+ self._woken = False
203
+ self._stopped = False
204
+
205
+ self._thread = threading.Thread(target=self._run, name="flowrensic-sink", daemon=True)
206
+ self._thread.start()
207
+
208
+ # What the worker calls.
209
+
210
+ def record(self, one: T) -> None:
211
+ """Puts one record in the buffer. Never waits on a send and never raises."""
212
+ try:
213
+ size = approx_size(one)
214
+ env = self._target.env_of(one)
215
+ with self._lock:
216
+ if self._stopped:
217
+ _add(self._dropped, env, 1)
218
+ self._dropped_total += 1
219
+ return
220
+ self._queue.append(_Entry(one, size))
221
+ self._queued_bytes += size
222
+ _add(self._waiting_by_env, env, 1)
223
+ self._drop_oldest()
224
+ if self._waiting_count() >= self._batch_size and not self._failing:
225
+ self._woken = True
226
+ self._wake.notify()
227
+ except Exception: # noqa: BLE001
228
+ return
229
+
230
+ def counts(self) -> SinkCounts:
231
+ with self._lock:
232
+ return SinkCounts(
233
+ dropped=self._dropped_total,
234
+ timed_out=self._timed_out_total,
235
+ waiting=self._waiting_count(),
236
+ waiting_bytes=self._waiting_bytes(),
237
+ )
238
+
239
+ @property
240
+ def batch_size(self) -> int:
241
+ """A send starts once this many records wait."""
242
+ return self._batch_size
243
+
244
+ @property
245
+ def flush_interval(self) -> float:
246
+ """Seconds between two ticks, each of which sends what waits."""
247
+ return self._flush_interval
248
+
249
+ @property
250
+ def timeout_ms(self) -> int:
251
+ """Milliseconds one send may take before it is given up."""
252
+ return self._timeout_ms
253
+
254
+ @property
255
+ def max_waiting(self) -> int:
256
+ """How many records may wait. Past it the oldest is dropped."""
257
+ return self._max_waiting
258
+
259
+ @property
260
+ def max_batch_size(self) -> int:
261
+ """How many records one send carries at most."""
262
+ return self._max_batch_size
263
+
264
+ @property
265
+ def max_waiting_bytes(self) -> int:
266
+ """How many bytes the records waiting may take, as approx_size gives it. Past it the oldest is dropped."""
267
+ return self._max_waiting_bytes
268
+
269
+ def flush_sync(self) -> None:
270
+ """Sends what waits and the counts, and returns once they landed or a
271
+ send went back. Blocks the calling thread."""
272
+ with self._sending:
273
+ tried = False
274
+ while True:
275
+ with self._lock:
276
+ if self._waiting_count() == 0 or (self._failing and tried):
277
+ break
278
+ tried = True
279
+ self._failing = False
280
+ self._send_while_answered()
281
+ self._report()
282
+
283
+ async def flush(self) -> None:
284
+ """Sends what waits and the counts, and waits for them to land, off the event loop."""
285
+ import asyncio
286
+
287
+ await asyncio.get_running_loop().run_in_executor(None, self.flush_sync)
288
+
289
+ def close_sync(self) -> None:
290
+ """Stops the thread, sends what waits once more, and drops what is left. Blocks the calling thread."""
291
+ with self._lock:
292
+ if self._stopped:
293
+ return
294
+ self._stopped = True
295
+ self._wake.notify()
296
+ self._thread.join()
297
+ self.flush_sync()
298
+ with self._sending:
299
+ with self._lock:
300
+ if self._kept is not None:
301
+ for one in self._kept.entries:
302
+ env = self._target.env_of(one.record)
303
+ _add(self._waiting_by_env, env, -1)
304
+ self._drop(env, 1)
305
+ self._kept = None
306
+ for one in self._take(len(self._queue)):
307
+ self._drop(self._target.env_of(one.record), 1)
308
+ self._report()
309
+
310
+ async def close(self) -> None:
311
+ """Sends what waits, stops, and drops what is left, off the event loop."""
312
+ import asyncio
313
+
314
+ await asyncio.get_running_loop().run_in_executor(None, self.close_sync)
315
+
316
+ # Inside, all with self._lock held unless said.
317
+
318
+ def _waiting_count(self) -> int:
319
+ return len(self._queue) + (len(self._kept.entries) if self._kept is not None else 0)
320
+
321
+ def _waiting_bytes(self) -> int:
322
+ return self._queued_bytes + (self._kept.size if self._kept is not None else 0)
323
+
324
+ def _drop(self, env: str, n: int) -> None:
325
+ _add(self._dropped, env, n)
326
+ self._dropped_total += n
327
+
328
+ def _take(self, count: int) -> list[_Entry[T]]:
329
+ out: list[_Entry[T]] = []
330
+ for _ in range(count):
331
+ if not self._queue:
332
+ break
333
+ one = self._queue.popleft()
334
+ _add(self._waiting_by_env, self._target.env_of(one.record), -1)
335
+ self._queued_bytes -= one.size
336
+ out.append(one)
337
+ return out
338
+
339
+ def _drop_oldest(self) -> None:
340
+ # Past either limit the oldest queued records go, never the kept send,
341
+ # which Flowrensic may already hold. The kept send does not count toward
342
+ # the count limit, and counts toward the memory limit.
343
+ excess = len(self._queue) - self._max_waiting
344
+ if excess > 0:
345
+ for one in self._take(excess):
346
+ self._drop(self._target.env_of(one.record), 1)
347
+ while self._queue and self._waiting_bytes() > self._max_waiting_bytes:
348
+ for one in self._take(1):
349
+ self._drop(self._target.env_of(one.record), 1)
350
+
351
+ def _next_send(self) -> Optional[_Kept[T]]:
352
+ if self._kept is not None:
353
+ out = self._kept
354
+ self._kept = None
355
+ for one in out.entries:
356
+ _add(self._waiting_by_env, self._target.env_of(one.record), -1)
357
+ return out
358
+ if not self._queue:
359
+ return None
360
+ entries = self._take(min(self._max_batch_size, len(self._queue)))
361
+ self._send_number += 1
362
+ return _Kept(entries, SendId(self._records_sender, self._send_number), sum(one.size for one in entries))
363
+
364
+ # Sending, with self._sending held and self._lock not held.
365
+
366
+ def _send_while_answered(self) -> None:
367
+ """Sends batch after batch while records wait, until a send goes back."""
368
+ while True:
369
+ with self._lock:
370
+ if self._failing:
371
+ return
372
+ send = self._next_send()
373
+ if send is None:
374
+ return
375
+ records = [one.record for one in send.entries]
376
+ try:
377
+ self._target.send(records, self._timeout_ms, send.id)
378
+ with self._lock:
379
+ self._failing = False
380
+ except BaseException as error: # noqa: BLE001
381
+ envs = {self._target.env_of(one) for one in records}
382
+ with self._lock:
383
+ if isinstance(error, ConnectError) and error.code == Code.DEADLINE_EXCEEDED:
384
+ for env in envs:
385
+ _add(self._timed_out, env, 1)
386
+ self._timed_out_total += 1
387
+ if _send_again(error):
388
+ self._failing = True
389
+ self._kept = send
390
+ for one in send.entries:
391
+ _add(self._waiting_by_env, self._target.env_of(one.record), 1)
392
+ self._drop_oldest()
393
+ return
394
+ for one in records:
395
+ self._drop(self._target.env_of(one), 1)
396
+
397
+ def _next_report(self) -> Optional[_KeptReport]:
398
+ envs = set(self._dropped) | set(self._timed_out) | set(self._waiting_by_env) | set(self._reported_waiting)
399
+ changed = any(
400
+ env in self._dropped
401
+ or env in self._timed_out
402
+ or self._waiting_by_env.get(env, 0) != self._reported_waiting.get(env, 0)
403
+ for env in envs
404
+ )
405
+ if not changed:
406
+ return None
407
+ dropped = dict(self._dropped)
408
+ timed_out = dict(self._timed_out)
409
+ waiting = dict(self._waiting_by_env)
410
+ reports = [
411
+ WorkerBufferReport(
412
+ env=env,
413
+ records_dropped=dropped.get(env, 0),
414
+ sends_timed_out=timed_out.get(env, 0),
415
+ records_waiting=waiting.get(env, 0),
416
+ )
417
+ for env in sorted(envs)
418
+ ]
419
+ self._report_number += 1
420
+ return _KeptReport(reports, SendId(self._reports_sender, self._report_number), dropped, timed_out, waiting)
421
+
422
+ def _report(self) -> None:
423
+ """At most one report at a time. A report that was not answered is sent
424
+ again as it was. Otherwise one goes only when a count changed since the
425
+ last report that was answered, and carries what was counted since."""
426
+ with self._lock:
427
+ if self._kept_report is None:
428
+ self._kept_report = self._next_report()
429
+ one = self._kept_report
430
+ if one is None:
431
+ return
432
+ try:
433
+ self._target.report(one.reports, self._timeout_ms, one.id)
434
+ except NotImplementedError:
435
+ with self._lock:
436
+ self._kept_report = None
437
+ return
438
+ except BaseException: # noqa: BLE001
439
+ return
440
+ with self._lock:
441
+ for env, n in one.dropped.items():
442
+ _add(self._dropped, env, -n)
443
+ for env, n in one.timed_out.items():
444
+ _add(self._timed_out, env, -n)
445
+ self._reported_waiting = one.waiting
446
+ self._kept_report = None
447
+
448
+ def _run(self) -> None:
449
+ """The thread: a tick every flush interval sends and reports, and a full
450
+ batch sends at once while sends are answered."""
451
+ next_tick = time.monotonic() + self._flush_interval
452
+ while True:
453
+ with self._lock:
454
+ while not self._stopped and not self._woken and time.monotonic() < next_tick:
455
+ self._wake.wait(max(0.0, next_tick - time.monotonic()))
456
+ if self._stopped:
457
+ return
458
+ woken = self._woken
459
+ self._woken = False
460
+ tick = time.monotonic() >= next_tick
461
+ if tick:
462
+ next_tick = time.monotonic() + self._flush_interval
463
+ self._failing = False
464
+ try:
465
+ with self._sending:
466
+ if tick or woken:
467
+ self._send_while_answered()
468
+ if tick:
469
+ self._report()
470
+ except BaseException: # noqa: BLE001
471
+ continue
472
+
473
+
474
+ def create_record_buffer(target: BufferTarget[T], **options: Any) -> RecordSink[T]:
475
+ """A record buffer that sends through target, with the buffer options given."""
476
+ return RecordSink(target, **options)
477
+
478
+
479
+ AttemptSink = RecordSink[ActivityAttempt]
480
+ """Buffers activity attempts and sends them to a Flowrensic server in batches."""
481
+
482
+
483
+ def default_worker_id() -> str:
484
+ return f"{socket.gethostname() or 'unknown'}-{os.getpid()}"
485
+
486
+
487
+ def client_headers(current_env: str, env_access_key: str, current_ephemeral_run: str) -> dict[str, str]:
488
+ """The headers every client call carries: the env, its access key and the ephemeral run. An empty value is not sent."""
489
+ headers: dict[str, str] = {}
490
+ if current_env:
491
+ headers[CURRENT_ENV_HEADER] = current_env
492
+ if env_access_key:
493
+ headers[ENV_ACCESS_KEY_HEADER] = env_access_key
494
+ if current_ephemeral_run:
495
+ headers[CURRENT_EPHEMERAL_RUN_HEADER] = current_ephemeral_run
496
+ return headers
497
+
498
+
499
+ def ingest_client_sync(server_base_url: str) -> IngestServiceClientSync:
500
+ return IngestServiceClientSync(server_base_url.rstrip("/"), send_compression=None)
501
+
502
+
503
+ class _AttemptTarget(BufferTarget[ActivityAttempt]):
504
+ def __init__(self, server_base_url: str, worker_id: str, headers: dict[str, str], worker_name: str = "") -> None:
505
+ self._ingest = ingest_client_sync(server_base_url)
506
+ self._worker_id = worker_id
507
+ self._worker_name = worker_name
508
+ self._headers = headers
509
+
510
+ def send(self, records: list[ActivityAttempt], timeout_ms: int, id: SendId) -> None:
511
+ self._ingest.ingest_activity_events(
512
+ IngestActivityEventsRequest(
513
+ events=records,
514
+ worker_id=self._worker_id,
515
+ worker_name=self._worker_name,
516
+ sender=id.sender,
517
+ send_number=id.send_number,
518
+ ),
519
+ headers=self._headers,
520
+ timeout_ms=max(1, timeout_ms),
521
+ )
522
+
523
+ def env_of(self, record: ActivityAttempt) -> str:
524
+ return record.env
525
+
526
+ def report(self, reports: list[WorkerBufferReport], timeout_ms: int, id: SendId) -> None:
527
+ self._ingest.ingest_activity_events(
528
+ IngestActivityEventsRequest(
529
+ reports=reports, worker_id=self._worker_id, sender=id.sender, send_number=id.send_number
530
+ ),
531
+ headers=self._headers,
532
+ timeout_ms=max(1, timeout_ms),
533
+ )
534
+
535
+
536
+ def create_attempt_sink(
537
+ server_base_url: str,
538
+ *,
539
+ worker_id: str = "",
540
+ worker_name: str = "",
541
+ current_env: str = "",
542
+ env_access_key: str = "",
543
+ current_ephemeral_run: str = "",
544
+ batch_size: int = DEFAULT_BATCH_SIZE,
545
+ flush_interval: float = DEFAULT_FLUSH_INTERVAL,
546
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
547
+ max_waiting: int = DEFAULT_MAX_WAITING,
548
+ max_batch_size: int = DEFAULT_MAX_BATCH_SIZE,
549
+ max_waiting_bytes: int = DEFAULT_MAX_WAITING_BYTES,
550
+ ) -> AttemptSink:
551
+ """A sink of activity attempts that sends them to the Flowrensic server at
552
+ server_base_url with IngestActivityEvents. Every call carries current_env,
553
+ env_access_key and current_ephemeral_run in their headers. worker_id
554
+ names this worker; empty, the host name and the process id joined by a
555
+ dash. worker_name is the name of the worker, the same for every copy of
556
+ it, sent with each attempt; empty, the worker has no name. Times are in
557
+ seconds."""
558
+ if not server_base_url:
559
+ raise ValueError("flowrensic: server_base_url is required")
560
+ return RecordSink(
561
+ _AttemptTarget(
562
+ server_base_url,
563
+ worker_id or default_worker_id(),
564
+ client_headers(current_env, env_access_key, current_ephemeral_run),
565
+ worker_name,
566
+ ),
567
+ batch_size=batch_size,
568
+ flush_interval=flush_interval,
569
+ timeout_ms=timeout_ms,
570
+ max_waiting=max_waiting,
571
+ max_batch_size=max_batch_size,
572
+ max_waiting_bytes=max_waiting_bytes,
573
+ )
574
+