blocklog 0.2.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.
Files changed (43) hide show
  1. blocklog/__init__.py +78 -0
  2. blocklog/_global.py +20 -0
  3. blocklog/_init_fn.py +95 -0
  4. blocklog/api/approval.py +182 -0
  5. blocklog/api/compliance.py +162 -0
  6. blocklog/api/decisions.py +177 -0
  7. blocklog/api/incidents.py +306 -0
  8. blocklog/api/replay.py +285 -0
  9. blocklog/api/traces.py +137 -0
  10. blocklog/api/verify.py +100 -0
  11. blocklog/approval.py +119 -0
  12. blocklog/async_client.py +28 -0
  13. blocklog/batching/buffer.py +22 -0
  14. blocklog/client.py +194 -0
  15. blocklog/compliance.py +95 -0
  16. blocklog/config.py +17 -0
  17. blocklog/context/managers.py +15 -0
  18. blocklog/context/vars.py +13 -0
  19. blocklog/decorators/__init__.py +4 -0
  20. blocklog/decorators/agent.py +219 -0
  21. blocklog/decorators/tool.py +206 -0
  22. blocklog/incident.py +89 -0
  23. blocklog/integrations/langchain.py +129 -0
  24. blocklog/integrations/langgraph.py +3 -0
  25. blocklog/integrations/openai_agents.py +3 -0
  26. blocklog/managers/__init__.py +3 -0
  27. blocklog/managers/decision.py +336 -0
  28. blocklog/middleware/hooks.py +11 -0
  29. blocklog/models/events.py +33 -0
  30. blocklog/models/responses.py +18 -0
  31. blocklog/replay.py +64 -0
  32. blocklog/signing/canonical.py +5 -0
  33. blocklog/signing/ed25519.py +25 -0
  34. blocklog/transport/auth.py +8 -0
  35. blocklog/transport/httpx_async.py +39 -0
  36. blocklog/transport/httpx_sync.py +36 -0
  37. blocklog/transport/retry.py +26 -0
  38. blocklog/verify.py +72 -0
  39. blocklog-0.2.0.dist-info/METADATA +272 -0
  40. blocklog-0.2.0.dist-info/RECORD +43 -0
  41. blocklog-0.2.0.dist-info/WHEEL +5 -0
  42. blocklog-0.2.0.dist-info/licenses/LICENSE +21 -0
  43. blocklog-0.2.0.dist-info/top_level.txt +1 -0
blocklog/api/replay.py ADDED
@@ -0,0 +1,285 @@
1
+ """
2
+ blocklog.api.replay
3
+ ~~~~~~~~~~~~~~~~~~~
4
+ Layer 2 client for forensic replay sessions.
5
+
6
+ Available via ``client.replay.*``.
7
+
8
+ Backend endpoints
9
+ -----------------
10
+ - POST /api/v1/forensics/replays
11
+ - GET /api/v1/forensics/replays/{id}
12
+ - GET /api/v1/forensics/replays/{id}/timeline
13
+ - GET /api/v1/forensics/replays/{id}/root-cause
14
+ - GET /api/v1/forensics/replays/{id}/causal-graph
15
+ - GET /api/v1/forensics/replays/{id}/staleness
16
+ - GET /api/v1/forensics/replays/{id}/divergence
17
+ - POST /api/v1/forensics/replays/{id}/counterfactuals
18
+ - POST /api/v1/forensics/compare
19
+ - GET /api/v1/forensics/compare/{id}
20
+ - POST /api/v1/replay/sessions (simple replay)
21
+ - GET /api/v1/replay/sessions/{id}
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ if TYPE_CHECKING:
28
+ from blocklog.client import BlocklogClient
29
+
30
+
31
+ class ReplaySession:
32
+ """A forensic replay session.
33
+
34
+ Wraps a backend replay session and provides lazy-loaded access to
35
+ all sub-resources: timeline, root cause, causal graph, staleness
36
+ heatmap, divergences, and counterfactuals.
37
+
38
+ Obtained from ``client.replay.create()`` or ``blocklog.replay()``.
39
+
40
+ Examples
41
+ --------
42
+ >>> session = client.replay.create(trace_id="trace-abc")
43
+ >>> session.timeline()
44
+ >>> session.root_cause()
45
+ >>> session.compare(other_trace_id="trace-def")
46
+ """
47
+
48
+ def __init__(self, data: dict[str, Any], client: "ReplayClient") -> None:
49
+ self._data = data
50
+ self._client = client
51
+ self.id: str = str(data.get("id", data.get("replay_session_id", "")))
52
+
53
+ # -- Raw data --
54
+
55
+ @property
56
+ def raw(self) -> dict[str, Any]:
57
+ """The full raw session data from the backend."""
58
+ return self._data
59
+
60
+ # -- Sub-resource accessors --
61
+
62
+ def timeline(self) -> list[dict[str, Any]]:
63
+ """Return the chronological event timeline for this replay.
64
+
65
+ Returns
66
+ -------
67
+ list[dict]
68
+ Ordered list of timeline items.
69
+ """
70
+ return self._client.timeline(self.id)
71
+
72
+ def root_cause(self) -> dict[str, Any]:
73
+ """Perform root-cause analysis on this replay.
74
+
75
+ The backend applies heuristics to detect:
76
+ - Stale context / data freshness violations
77
+ - Policy violations (authorization denied)
78
+ - Integrity failures (receipt status anomalies)
79
+
80
+ Returns
81
+ -------
82
+ dict
83
+ Keys: ``detected``, ``root_cause_type``, ``description``,
84
+ ``confidence``, ``remediation``.
85
+ """
86
+ return self._client.root_cause(self.id)
87
+
88
+ def causal_graph(self) -> dict[str, Any]:
89
+ """Return the causal graph (nodes + edges) for this replay.
90
+
91
+ Useful for visualising the chain of causality across agents,
92
+ tools, decisions, and execution receipts.
93
+
94
+ Returns
95
+ -------
96
+ dict
97
+ Keys: ``nodes``, ``edges``.
98
+ """
99
+ return self._client.causal_graph(self.id)
100
+
101
+ def staleness(self) -> dict[str, Any]:
102
+ """Return the staleness heatmap for data sources used in this replay.
103
+
104
+ Returns
105
+ -------
106
+ dict
107
+ Keys: ``overall_staleness_rating``, ``findings``.
108
+ """
109
+ return self._client.staleness(self.id)
110
+
111
+ def divergence(self) -> list[dict[str, Any]]:
112
+ """Return divergence analysis results for this replay.
113
+
114
+ Returns
115
+ -------
116
+ list[dict]
117
+ List of detected divergence events.
118
+ """
119
+ return self._client.divergence(self.id)
120
+
121
+ def counterfactual(
122
+ self,
123
+ token_id: str,
124
+ *,
125
+ modified_inputs: dict[str, Any],
126
+ ) -> dict[str, Any]:
127
+ """Run a counterfactual (what-if) simulation on this replay.
128
+
129
+ Parameters
130
+ ----------
131
+ token_id:
132
+ The execution token ID to simulate against.
133
+ modified_inputs:
134
+ Dict of input fields to override in the simulation.
135
+
136
+ Returns
137
+ -------
138
+ dict
139
+ Counterfactual analysis result.
140
+ """
141
+ return self._client.counterfactual(self.id, token_id=token_id, modified_inputs=modified_inputs)
142
+
143
+ def compare(self, other_trace_id: str) -> dict[str, Any]:
144
+ """Compare this replay session against another trace.
145
+
146
+ Creates a new replay session for ``other_trace_id``, then runs a
147
+ forensic comparison against this session.
148
+
149
+ Parameters
150
+ ----------
151
+ other_trace_id:
152
+ Trace ID of the session to compare against.
153
+
154
+ Returns
155
+ -------
156
+ dict
157
+ Comparison result including ``differences`` list.
158
+ """
159
+ other = self._client.create(trace_id=other_trace_id)
160
+ return self._client.compare(self.id, other.id)
161
+
162
+ def __repr__(self) -> str:
163
+ return f"<ReplaySession id={self.id!r}>"
164
+
165
+
166
+ class ReplayClient:
167
+ """Manage forensic replay sessions.
168
+
169
+ Accessed as ``client.replay``.
170
+
171
+ Examples
172
+ --------
173
+ >>> session = client.replay.create(trace_id="trace-abc")
174
+ >>> session.root_cause()
175
+ >>> session.compare("trace-def")
176
+ """
177
+
178
+ def __init__(self, client: "BlocklogClient") -> None:
179
+ self._client = client
180
+
181
+ def create(
182
+ self,
183
+ trace_id: str,
184
+ *,
185
+ token_id: str | None = None,
186
+ metadata: dict[str, Any] | None = None,
187
+ ) -> ReplaySession:
188
+ """Create a forensic replay session for a given trace.
189
+
190
+ Parameters
191
+ ----------
192
+ trace_id:
193
+ The trace to replay.
194
+ token_id:
195
+ Execution token ID to bind to this session.
196
+ metadata:
197
+ Extra metadata for the replay session.
198
+
199
+ Returns
200
+ -------
201
+ ReplaySession
202
+ A session handle with access to all forensic sub-resources.
203
+ """
204
+ payload: dict[str, Any] = {"trace_id": trace_id}
205
+ if token_id is not None:
206
+ payload["token_id"] = token_id
207
+ if metadata is not None:
208
+ payload["metadata"] = metadata
209
+
210
+ data = self._client.retry.run(
211
+ lambda: self._client.transport.request("POST", "/forensics/replays", json=payload)
212
+ )
213
+ return ReplaySession(data, self)
214
+
215
+ def get(self, replay_session_id: str) -> ReplaySession:
216
+ """Fetch an existing replay session by ID."""
217
+ data = self._client.retry.run(
218
+ lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}")
219
+ )
220
+ return ReplaySession(data, self)
221
+
222
+ def timeline(self, replay_session_id: str) -> list[dict[str, Any]]:
223
+ """Return the event timeline for a replay session."""
224
+ return self._client.retry.run(
225
+ lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/timeline")
226
+ )
227
+
228
+ def root_cause(self, replay_session_id: str) -> dict[str, Any]:
229
+ """Run root-cause analysis on a replay session."""
230
+ return self._client.retry.run(
231
+ lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/root-cause")
232
+ )
233
+
234
+ def causal_graph(self, replay_session_id: str) -> dict[str, Any]:
235
+ """Return the causal graph for a replay session."""
236
+ return self._client.retry.run(
237
+ lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/causal-graph")
238
+ )
239
+
240
+ def staleness(self, replay_session_id: str) -> dict[str, Any]:
241
+ """Return staleness heatmap for a replay session."""
242
+ return self._client.retry.run(
243
+ lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/staleness")
244
+ )
245
+
246
+ def divergence(self, replay_session_id: str) -> list[dict[str, Any]]:
247
+ """Return divergence analysis for a replay session."""
248
+ return self._client.retry.run(
249
+ lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/divergence")
250
+ )
251
+
252
+ def counterfactual(
253
+ self,
254
+ replay_session_id: str,
255
+ *,
256
+ token_id: str,
257
+ modified_inputs: dict[str, Any],
258
+ ) -> dict[str, Any]:
259
+ """Run a counterfactual simulation."""
260
+ return self._client.retry.run(
261
+ lambda: self._client.transport.request(
262
+ "POST",
263
+ f"/forensics/replays/{replay_session_id}/counterfactuals",
264
+ json={"token_id": token_id, "modified_inputs": modified_inputs},
265
+ )
266
+ )
267
+
268
+ def compare(
269
+ self,
270
+ baseline_session_id: str,
271
+ candidate_session_id: str,
272
+ ) -> dict[str, Any]:
273
+ """Compare two replay sessions and return a diff of differences."""
274
+ return self._client.retry.run(
275
+ lambda: self._client.transport.request("POST", "/forensics/compare", json={
276
+ "baseline_session_id": baseline_session_id,
277
+ "candidate_session_id": candidate_session_id,
278
+ })
279
+ )
280
+
281
+ def get_comparison(self, comparison_id: str) -> dict[str, Any]:
282
+ """Retrieve a previously computed replay comparison."""
283
+ return self._client.retry.run(
284
+ lambda: self._client.transport.request("GET", f"/forensics/compare/{comparison_id}")
285
+ )
blocklog/api/traces.py ADDED
@@ -0,0 +1,137 @@
1
+ """
2
+ blocklog.api.traces
3
+ ~~~~~~~~~~~~~~~~~~~
4
+ Layer 2 client for trace and session queries.
5
+
6
+ Available via ``client.traces.*``.
7
+
8
+ Backend endpoints
9
+ -----------------
10
+ - GET /api/v1/traces
11
+ - GET /api/v1/traces/{trace_id}
12
+ - GET /api/v1/sessions/{session_id}/timeline
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from blocklog.client import BlocklogClient
20
+
21
+
22
+ class TracesClient:
23
+ """Query traces and session timelines.
24
+
25
+ Accessed as ``client.traces``.
26
+
27
+ Examples
28
+ --------
29
+ >>> traces = client.traces.list(event_type="DECISION_COMPLETE", limit=20)
30
+ >>> detail = client.traces.get("trace-uuid")
31
+ >>> timeline = client.traces.session_timeline("session-uuid")
32
+ """
33
+
34
+ def __init__(self, client: "BlocklogClient") -> None:
35
+ self._client = client
36
+
37
+ def list(
38
+ self,
39
+ *,
40
+ trace_id: str | None = None,
41
+ session_id: str | None = None,
42
+ workflow_id: str | None = None,
43
+ source: str | None = None,
44
+ event_type: str | None = None,
45
+ from_ts: str | None = None,
46
+ to_ts: str | None = None,
47
+ limit: int = 50,
48
+ ) -> list[dict[str, Any]]:
49
+ """List traces with optional filters.
50
+
51
+ Parameters
52
+ ----------
53
+ trace_id:
54
+ Filter to a specific trace.
55
+ session_id:
56
+ Filter to a specific session.
57
+ workflow_id:
58
+ Filter to a specific workflow.
59
+ source:
60
+ Filter by event source string.
61
+ event_type:
62
+ Filter by event type (e.g. ``"DECISION_COMPLETE"``).
63
+ from_ts:
64
+ ISO-8601 lower bound for event timestamp.
65
+ to_ts:
66
+ ISO-8601 upper bound for event timestamp.
67
+ limit:
68
+ Maximum number of results (1–200, default 50).
69
+
70
+ Returns
71
+ -------
72
+ list[dict]
73
+ List of trace/log records.
74
+ """
75
+ params: dict[str, Any] = {"limit": limit}
76
+ if trace_id:
77
+ params["trace_id"] = trace_id
78
+ if session_id:
79
+ params["session_id"] = session_id
80
+ if workflow_id:
81
+ params["workflow_id"] = workflow_id
82
+ if source:
83
+ params["source"] = source
84
+ if event_type:
85
+ params["event_type"] = event_type
86
+ if from_ts:
87
+ params["from"] = from_ts
88
+ if to_ts:
89
+ params["to"] = to_ts
90
+
91
+ result = self._client.retry.run(
92
+ lambda: self._client.transport.request("GET", "/traces", params=params)
93
+ )
94
+ return result.get("items", result) if isinstance(result, dict) else result
95
+
96
+ def get(self, trace_id: str) -> dict[str, Any]:
97
+ """Fetch detailed information about a specific trace.
98
+
99
+ Parameters
100
+ ----------
101
+ trace_id:
102
+ UUID of the trace.
103
+ """
104
+ return self._client.retry.run(
105
+ lambda: self._client.transport.request("GET", f"/traces/{trace_id}")
106
+ )
107
+
108
+ def session_timeline(
109
+ self,
110
+ session_id: str,
111
+ *,
112
+ cursor: str | None = None,
113
+ limit: int = 100,
114
+ ) -> dict[str, Any]:
115
+ """Return the paginated event timeline for a session.
116
+
117
+ Parameters
118
+ ----------
119
+ session_id:
120
+ UUID of the session.
121
+ cursor:
122
+ Pagination cursor from a previous response.
123
+ limit:
124
+ Max events to return (1–500, default 100).
125
+
126
+ Returns
127
+ -------
128
+ dict
129
+ Paginated response with ``items`` and optional ``next_cursor``.
130
+ """
131
+ params: dict[str, Any] = {"limit": limit}
132
+ if cursor:
133
+ params["cursor"] = cursor
134
+
135
+ return self._client.retry.run(
136
+ lambda: self._client.transport.request("GET", f"/sessions/{session_id}/timeline", params=params)
137
+ )
blocklog/api/verify.py ADDED
@@ -0,0 +1,100 @@
1
+ """
2
+ blocklog.api.verify
3
+ ~~~~~~~~~~~~~~~~~~~
4
+ Layer 2 client for cryptographic verification.
5
+
6
+ Available via ``client.verify.*``.
7
+
8
+ Backend endpoints
9
+ -----------------
10
+ - GET /api/v1/verify/log/{log_id}
11
+ - GET /api/v1/verify/batch/{batch_id}
12
+ - GET /api/v1/decisions/{id}/verify
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from blocklog.client import BlocklogClient
20
+
21
+
22
+ class VerifyClient:
23
+ """Cryptographically verify logs, batches, and decisions.
24
+
25
+ Every item recorded by Blocklog is anchored to a blockchain via a
26
+ Merkle tree. These methods let you prove that a specific log entry
27
+ or batch has not been tampered with since it was anchored.
28
+
29
+ Accessed as ``client.verify``.
30
+
31
+ Examples
32
+ --------
33
+ >>> result = client.verify.log("log-uuid-here")
34
+ >>> print(result["status"]) # "verified"
35
+ >>> print(result["merkle_proof"])
36
+
37
+ >>> result = client.verify.decision("dec-uuid-here")
38
+ >>> print(result["status"]) # "verified"
39
+ """
40
+
41
+ def __init__(self, client: "BlocklogClient") -> None:
42
+ self._client = client
43
+
44
+ def log(self, log_id: str) -> dict[str, Any]:
45
+ """Verify a single log entry against its Merkle proof.
46
+
47
+ Parameters
48
+ ----------
49
+ log_id:
50
+ UUID of the log entry to verify.
51
+
52
+ Returns
53
+ -------
54
+ dict
55
+ Keys: ``status``, ``merkle_proof``, ``batch_anchor``,
56
+ ``time_attestation``, ``details``.
57
+
58
+ Raises
59
+ ------
60
+ httpx.HTTPStatusError
61
+ If the log is not found or verification fails.
62
+ """
63
+ return self._client.retry.run(
64
+ lambda: self._client.transport.request("GET", f"/verify/log/{log_id}")
65
+ )
66
+
67
+ def batch(self, batch_id: str) -> dict[str, Any]:
68
+ """Verify an entire batch against its blockchain anchor.
69
+
70
+ Parameters
71
+ ----------
72
+ batch_id:
73
+ ID of the batch to verify.
74
+
75
+ Returns
76
+ -------
77
+ dict
78
+ Keys: ``status``, ``anchor_tx``, ``timestamp``,
79
+ ``time_attestation``, ``details``.
80
+ """
81
+ return self._client.retry.run(
82
+ lambda: self._client.transport.request("GET", f"/verify/batch/{batch_id}")
83
+ )
84
+
85
+ def decision(self, decision_id: str) -> dict[str, Any]:
86
+ """Verify all evidence attached to a decision.
87
+
88
+ Parameters
89
+ ----------
90
+ decision_id:
91
+ UUID of the decision to verify.
92
+
93
+ Returns
94
+ -------
95
+ dict
96
+ Verification summary including Merkle and blockchain evidence.
97
+ """
98
+ return self._client.retry.run(
99
+ lambda: self._client.transport.request("GET", f"/decisions/{decision_id}/verify")
100
+ )
blocklog/approval.py ADDED
@@ -0,0 +1,119 @@
1
+ """
2
+ blocklog.approval
3
+ ~~~~~~~~~~~~~~~~~
4
+ Module-level namespace for human approval workflows.
5
+
6
+ Usage (Layer 1)::
7
+
8
+ from blocklog import approval
9
+
10
+ approval.request(
11
+ decision_id="dec_abc123",
12
+ reason="Trade exceeds $500k threshold",
13
+ reviewer="risk-team@fund.com",
14
+ )
15
+
16
+ approval.reject(reviewer="alice@fund.com", reason="Insufficient data")
17
+
18
+ approval.escalate(
19
+ from_reviewer="alice@fund.com",
20
+ to_reviewer="head-of-risk@fund.com",
21
+ reason="Requires executive sign-off",
22
+ )
23
+
24
+ trail = approval.audit_trail()
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+
30
+
31
+ def request(
32
+ decision_id: str | None = None,
33
+ *,
34
+ reason: str,
35
+ reviewer: str | None = None,
36
+ log_id: str | None = None,
37
+ metadata: dict[str, Any] | None = None,
38
+ ) -> dict[str, Any]:
39
+ """Request human approval for a decision.
40
+
41
+ This is a **non-blocking** call — it registers the approval request
42
+ and triggers configured webhooks/notifications, then returns
43
+ immediately.
44
+
45
+ Parameters
46
+ ----------
47
+ decision_id:
48
+ UUID of the decision requiring approval.
49
+ reason:
50
+ Human-readable explanation of why approval is needed.
51
+ reviewer:
52
+ Email / identifier of the intended reviewer.
53
+ log_id:
54
+ UUID of a specific log entry, if approval is on a log rather
55
+ than a top-level decision.
56
+ metadata:
57
+ Extra context to store with the approval request.
58
+ """
59
+ from blocklog._global import get_client
60
+ return get_client().approval.request(
61
+ decision_id=decision_id,
62
+ reason=reason,
63
+ reviewer=reviewer,
64
+ log_id=log_id,
65
+ metadata=metadata,
66
+ )
67
+
68
+
69
+ def reject(reviewer: str, *, reason: str, decision_id: str | None = None) -> dict[str, Any]:
70
+ """Record that a reviewer has rejected a decision.
71
+
72
+ Parameters
73
+ ----------
74
+ reviewer:
75
+ Identity of the person rejecting.
76
+ reason:
77
+ Explanation for the rejection.
78
+ decision_id:
79
+ Optional reference to the decision being rejected.
80
+ """
81
+ from blocklog._global import get_client
82
+ return get_client().approval.reject(reviewer=reviewer, reason=reason, decision_id=decision_id)
83
+
84
+
85
+ def escalate(
86
+ from_reviewer: str,
87
+ to_reviewer: str,
88
+ *,
89
+ reason: str,
90
+ ) -> dict[str, Any]:
91
+ """Escalate an approval request to a different reviewer.
92
+
93
+ Parameters
94
+ ----------
95
+ from_reviewer:
96
+ Current reviewer escalating the decision.
97
+ to_reviewer:
98
+ Target reviewer who should take over.
99
+ reason:
100
+ Explanation for the escalation.
101
+ """
102
+ from blocklog._global import get_client
103
+ return get_client().approval.escalate(
104
+ from_reviewer=from_reviewer,
105
+ to_reviewer=to_reviewer,
106
+ reason=reason,
107
+ )
108
+
109
+
110
+ def list_overrides() -> list[dict[str, Any]]:
111
+ """Return all HITL override records for the company."""
112
+ from blocklog._global import get_client
113
+ return get_client().approval.list_overrides()
114
+
115
+
116
+ def audit_trail() -> list[dict[str, Any]]:
117
+ """Return the full HITL audit trail in reverse-chronological order."""
118
+ from blocklog._global import get_client
119
+ return get_client().approval.audit_trail()
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from blocklog.client import BlocklogClient
4
+ from blocklog.config import BlocklogConfig
5
+ from blocklog.models.responses import IngestResponse
6
+ from blocklog.transport.httpx_async import AsyncTransport
7
+
8
+
9
+ class AsyncBlocklogClient(BlocklogClient):
10
+ def __init__(self, config: BlocklogConfig) -> None:
11
+ super().__init__(config)
12
+ self.transport = AsyncTransport(
13
+ base_url=config.base_url,
14
+ api_key=config.api_key,
15
+ timeout=config.timeout,
16
+ )
17
+
18
+ async def event(self, event_type: str, payload: dict, **kwargs) -> IngestResponse:
19
+ envelope = self._build_event(event_type=event_type, payload=payload, **kwargs)
20
+ result = await self.transport.request("POST", "/logs", json=self._serialize(envelope))
21
+ return IngestResponse.model_validate(result)
22
+
23
+ async def flush(self, *, batch=None):
24
+ batch = batch or self.buffer.flush()
25
+ if not batch:
26
+ return {"ingested": 0, "log_ids": []}
27
+ payload = {"logs": [self._serialize(item) for item in batch]}
28
+ return await self.transport.request("POST", "/logs/batch", json=payload)
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from blocklog.models.events import EventEnvelope
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class EventBuffer:
10
+ batch_size: int
11
+ items: list[EventEnvelope] = field(default_factory=list)
12
+
13
+ def add(self, event: EventEnvelope) -> list[EventEnvelope] | None:
14
+ self.items.append(event)
15
+ if len(self.items) >= self.batch_size:
16
+ return self.flush()
17
+ return None
18
+
19
+ def flush(self) -> list[EventEnvelope]:
20
+ drained = list(self.items)
21
+ self.items.clear()
22
+ return drained