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/client.py ADDED
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ from hashlib import sha256
4
+ from typing import Any
5
+
6
+ from blocklog.api.approval import ApprovalClient
7
+ from blocklog.api.compliance import ComplianceClient
8
+ from blocklog.api.decisions import DecisionsClient
9
+ from blocklog.api.incidents import IncidentsClient
10
+ from blocklog.api.replay import ReplayClient
11
+ from blocklog.api.traces import TracesClient
12
+ from blocklog.api.verify import VerifyClient
13
+ from blocklog.batching.buffer import EventBuffer
14
+ from blocklog.config import BlocklogConfig
15
+ from blocklog.context.managers import agent_session
16
+ from blocklog.context.vars import get_context
17
+ from blocklog.integrations.langchain import instrument_langchain
18
+ from blocklog.integrations.langgraph import instrument_langgraph
19
+ from blocklog.integrations.openai_agents import instrument_openai_agents
20
+ from blocklog.middleware.hooks import apply_hooks
21
+ from blocklog.models.events import EventEnvelope
22
+ from blocklog.models.responses import IngestResponse
23
+ from blocklog.signing.ed25519 import hash_sign
24
+ from blocklog.transport.httpx_sync import SyncTransport
25
+ from blocklog.transport.retry import RetryPolicy
26
+
27
+
28
+ class BlocklogClient:
29
+ """The Blocklog client.
30
+
31
+ Most users should call ``blocklog.init(api_key=...)`` instead of
32
+ instantiating this class directly. The global client is then
33
+ accessible to all module-level helpers (``decision``, ``approval``,
34
+ ``incident``, ``replay``, ``verify``, ``compliance``).
35
+
36
+ For advanced control, instantiate this class directly::
37
+
38
+ from blocklog import BlocklogClient, BlocklogConfig
39
+
40
+ client = BlocklogClient(BlocklogConfig(api_key="blk_..."))
41
+
42
+ Attributes
43
+ ----------
44
+ decisions : DecisionsClient
45
+ Layer 2 client for AI Decision records.
46
+ incidents : IncidentsClient
47
+ Layer 2 client for the incident lifecycle.
48
+ approval : ApprovalClient
49
+ Layer 2 client for HITL approval workflows.
50
+ replay : ReplayClient
51
+ Layer 2 client for forensic replay sessions.
52
+ compliance : ComplianceClient
53
+ Layer 2 client for compliance report generation.
54
+ verify : VerifyClient
55
+ Layer 2 client for cryptographic verification.
56
+ traces : TracesClient
57
+ Layer 2 client for trace/session queries.
58
+ """
59
+
60
+ def __init__(self, config: BlocklogConfig) -> None:
61
+ self.config = config
62
+ self.transport = SyncTransport(
63
+ base_url=config.base_url,
64
+ api_key=config.api_key,
65
+ timeout=config.timeout,
66
+ )
67
+ self.retry = RetryPolicy(max_retries=config.max_retries)
68
+ self.buffer = EventBuffer(batch_size=config.batch_size)
69
+ self.hooks: list = []
70
+
71
+ # ── Layer 2 domain sub-clients ────────────────────────────────
72
+ self.decisions = DecisionsClient(self)
73
+ self.incidents = IncidentsClient(self)
74
+ self.approval = ApprovalClient(self)
75
+ self.replay = ReplayClient(self)
76
+ self.compliance = ComplianceClient(self)
77
+ self.verify = VerifyClient(self)
78
+ self.traces = TracesClient(self)
79
+
80
+ # ── Legacy aliases (backward compatibility) ───────────────────
81
+ # These point to the same new clients so old code keeps working.
82
+ self.forensics = self.replay # client.forensics.compare(...) still works
83
+ self.hitl = self.approval # client.hitl.reject(...) still works
84
+
85
+ @classmethod
86
+ def from_env(cls) -> "BlocklogClient":
87
+ """Create a client configured entirely from environment variables."""
88
+ return cls(BlocklogConfig.from_env())
89
+
90
+ def add_hook(self, hook) -> "BlocklogClient":
91
+ """Register a middleware hook applied to every outbound event payload."""
92
+ self.hooks.append(hook)
93
+ return self
94
+
95
+ def session(self, *, agent_id: str | None = None, source: str = "python-sdk", workflow_id=None):
96
+ """Open an ``agent_session`` context manager (backward-compatible).
97
+
98
+ Prefer the ``@blocklog.agent`` decorator for new code.
99
+ """
100
+ return agent_session(agent_id=agent_id, source=source, workflow_id=workflow_id)
101
+
102
+ def instrument_openai_agents(self) -> "BlocklogClient":
103
+ """Auto-instrument the OpenAI Agents SDK."""
104
+ return instrument_openai_agents(self)
105
+
106
+ def instrument_langchain(self) -> "BlocklogClient":
107
+ """Auto-instrument LangChain."""
108
+ return instrument_langchain(self)
109
+
110
+ def instrument_langgraph(self) -> "BlocklogClient":
111
+ """Auto-instrument LangGraph."""
112
+ return instrument_langgraph(self)
113
+
114
+ # ── Low-level event ingest (Layer 3) ─────────────────────────────
115
+
116
+ def event(self, event_type: str, payload: dict[str, Any], **kwargs) -> IngestResponse:
117
+ """Emit a single event immediately (synchronous)."""
118
+ envelope = self._build_event(event_type=event_type, payload=payload, **kwargs)
119
+ result = self.retry.run(lambda: self.transport.request("POST", "/logs", json=self._serialize(envelope)))
120
+ return IngestResponse.model_validate(result)
121
+
122
+ def enqueue(self, event_type: str, payload: dict[str, Any], **kwargs):
123
+ """Enqueue an event for batched delivery."""
124
+ envelope = self._build_event(event_type=event_type, payload=payload, **kwargs)
125
+ batch = self.buffer.add(envelope)
126
+ if batch:
127
+ return self.flush(batch=batch)
128
+ return None
129
+
130
+ def flush(self, *, batch=None):
131
+ """Flush the event buffer, sending all queued events."""
132
+ batch = batch or self.buffer.flush()
133
+ if not batch:
134
+ return {"ingested": 0, "log_ids": []}
135
+ payload = {"logs": [self._serialize(item) for item in batch]}
136
+ return self.retry.run(lambda: self.transport.request("POST", "/logs/batch", json=payload))
137
+
138
+ def _build_event(self, *, event_type: str, payload: dict[str, Any], **kwargs) -> EventEnvelope:
139
+ context = get_context()
140
+ event = EventEnvelope(
141
+ event_type=event_type,
142
+ payload=payload,
143
+ source=kwargs.get("source") or (context.source if context else "python-sdk"),
144
+ trace_id=kwargs.get("trace_id") or (context.trace_id if context else None),
145
+ session_id=kwargs.get("session_id") or (context.session_id if context else None),
146
+ workflow_id=kwargs.get("workflow_id") or (context.workflow_id if context else None),
147
+ actor_id=kwargs.get("actor_id") or (context.agent_id if context else None),
148
+ actor_type=kwargs.get("actor_type", "agent"),
149
+ parent_event_id=kwargs.get("parent_event_id"),
150
+ root_event_id=kwargs.get("root_event_id"),
151
+ span_id=kwargs.get("span_id"),
152
+ attempt_no=kwargs.get("attempt_no", 1),
153
+ causality_type=kwargs.get("causality_type"),
154
+ agent_metadata=kwargs.get("agent_metadata", {}),
155
+ )
156
+ if not event.idempotency_key:
157
+ event.idempotency_key = self._idempotency_key(event)
158
+ return event
159
+
160
+ def _serialize(self, envelope: EventEnvelope) -> dict[str, Any]:
161
+ payload = {
162
+ "event_type": envelope.event_type,
163
+ "timestamp": envelope.timestamp.isoformat(),
164
+ "data": envelope.payload,
165
+ "source": envelope.source,
166
+ "idempotency_key": envelope.idempotency_key,
167
+ "trace_id": str(envelope.trace_id) if envelope.trace_id else None,
168
+ "session_id": str(envelope.session_id) if envelope.session_id else None,
169
+ "workflow_id": str(envelope.workflow_id) if envelope.workflow_id else None,
170
+ "parent_event_id": str(envelope.parent_event_id) if envelope.parent_event_id else None,
171
+ "root_event_id": str(envelope.root_event_id) if envelope.root_event_id else None,
172
+ "span_id": envelope.span_id,
173
+ "attempt_no": envelope.attempt_no,
174
+ "causality_type": envelope.causality_type,
175
+ "schema_version": envelope.schema_version,
176
+ "event_version": envelope.event_version,
177
+ "actor_type": envelope.actor_type,
178
+ "actor_id": envelope.actor_id,
179
+ "agent_metadata": envelope.agent_metadata,
180
+ }
181
+ payload = apply_hooks(payload, self.hooks)
182
+ if self.config.signing_key:
183
+ payload["log_signature"] = hash_sign(payload, private_key=self.config.signing_key)
184
+ return payload
185
+
186
+ @staticmethod
187
+ def _idempotency_key(envelope: EventEnvelope) -> str:
188
+ digest = sha256(
189
+ f"{envelope.event_type}:{envelope.source}:{envelope.trace_id}:{envelope.session_id}:{envelope.payload}".encode(
190
+ "utf-8"
191
+ )
192
+ ).hexdigest()[:32]
193
+ return f"blk_{digest}"
194
+
blocklog/compliance.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ blocklog.compliance
3
+ ~~~~~~~~~~~~~~~~~~~
4
+ Module-level namespace for compliance report generation.
5
+
6
+ Usage (Layer 1)::
7
+
8
+ import blocklog
9
+
10
+ report = blocklog.compliance.generate(
11
+ trace_id="trace-abc",
12
+ framework="SOC2",
13
+ )
14
+ print(report["id"])
15
+
16
+ dashboard = blocklog.compliance.dashboard()
17
+ reports = blocklog.compliance.list()
18
+ share_url = blocklog.compliance.share(report["id"], expires_in=86400)
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+
25
+ def generate(
26
+ trace_id: str | None = None,
27
+ *,
28
+ framework: str | None = None,
29
+ date_from: str | None = None,
30
+ date_to: str | None = None,
31
+ metadata: dict[str, Any] | None = None,
32
+ ) -> dict[str, Any]:
33
+ """Generate a compliance report.
34
+
35
+ Parameters
36
+ ----------
37
+ trace_id:
38
+ Scope the report to a specific trace. Omit for a company-wide
39
+ report.
40
+ framework:
41
+ Compliance framework (``"SOC2"``, ``"GDPR"``, ``"ISO27001"``…).
42
+ date_from:
43
+ ISO-8601 start of the reporting window.
44
+ date_to:
45
+ ISO-8601 end of the reporting window.
46
+ metadata:
47
+ Arbitrary extra data to embed in the report.
48
+ """
49
+ from blocklog._global import get_client
50
+ return get_client().compliance.generate(
51
+ trace_id=trace_id,
52
+ framework=framework,
53
+ date_from=date_from,
54
+ date_to=date_to,
55
+ metadata=metadata,
56
+ )
57
+
58
+
59
+ def get(report_id: str) -> dict[str, Any]:
60
+ """Fetch a compliance report by ID."""
61
+ from blocklog._global import get_client
62
+ return get_client().compliance.get(report_id)
63
+
64
+
65
+ def list() -> list[dict[str, Any]]: # noqa: A001
66
+ """List all compliance reports for the company."""
67
+ from blocklog._global import get_client
68
+ return get_client().compliance.list()
69
+
70
+
71
+ def dashboard() -> dict[str, Any]:
72
+ """Return the compliance dashboard summary."""
73
+ from blocklog._global import get_client
74
+ return get_client().compliance.dashboard()
75
+
76
+
77
+ def share(
78
+ report_id: str,
79
+ *,
80
+ expires_in: int | None = None,
81
+ recipient_email: str | None = None,
82
+ ) -> dict[str, Any]:
83
+ """Create a shareable link for a compliance report."""
84
+ from blocklog._global import get_client
85
+ return get_client().compliance.share(
86
+ report_id=report_id,
87
+ expires_in=expires_in,
88
+ recipient_email=recipient_email,
89
+ )
90
+
91
+
92
+ def export(report_id: str, *, download: bool = False) -> dict[str, Any]:
93
+ """Export a compliance report as JSON."""
94
+ from blocklog._global import get_client
95
+ return get_client().compliance.export(report_id=report_id, download=download)
blocklog/config.py ADDED
@@ -0,0 +1,17 @@
1
+ from os import getenv
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class BlocklogConfig(BaseModel):
7
+ base_url: str = Field(default_factory=lambda: getenv("BLOCKLOG_BASE_URL", "http://127.0.0.1:8000/api/v1"))
8
+ api_key: str = Field(default_factory=lambda: getenv("BLOCKLOG_API_KEY", ""))
9
+ signing_key: str = Field(default_factory=lambda: getenv("BLOCKLOG_SDK_SIGNING_KEY", ""))
10
+ timeout: float = Field(default_factory=lambda: float(getenv("BLOCKLOG_TIMEOUT", "10")))
11
+ max_retries: int = Field(default_factory=lambda: int(getenv("BLOCKLOG_MAX_RETRIES", "3")))
12
+ batch_size: int = Field(default_factory=lambda: int(getenv("BLOCKLOG_BATCH_SIZE", "100")))
13
+ flush_interval: float = Field(default_factory=lambda: float(getenv("BLOCKLOG_FLUSH_INTERVAL", "2")))
14
+
15
+ @classmethod
16
+ def from_env(cls) -> "BlocklogConfig":
17
+ return cls()
@@ -0,0 +1,15 @@
1
+ from contextlib import contextmanager
2
+
3
+ from blocklog.models.events import SessionContext
4
+
5
+ from .vars import set_context
6
+
7
+
8
+ @contextmanager
9
+ def agent_session(*, agent_id: str | None = None, source: str = "python-sdk", workflow_id=None):
10
+ context = SessionContext(agent_id=agent_id, source=source, workflow_id=workflow_id)
11
+ token = set_context(context)
12
+ try:
13
+ yield context
14
+ finally:
15
+ set_context(None)
@@ -0,0 +1,13 @@
1
+ from contextvars import ContextVar
2
+
3
+ from blocklog.models.events import SessionContext
4
+
5
+ _context: ContextVar[SessionContext | None] = ContextVar("blocklog_context", default=None)
6
+
7
+
8
+ def set_context(context: SessionContext | None):
9
+ return _context.set(context)
10
+
11
+
12
+ def get_context() -> SessionContext | None:
13
+ return _context.get()
@@ -0,0 +1,4 @@
1
+ from blocklog.decorators.agent import agent
2
+ from blocklog.decorators.tool import tool
3
+
4
+ __all__ = ["agent", "tool"]
@@ -0,0 +1,219 @@
1
+ """
2
+ blocklog.decorators.agent
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ The ``@agent`` decorator — wraps a function (or class) so that every
5
+ execution is automatically traced, timed, and linked to a Blocklog
6
+ session.
7
+
8
+ Usage::
9
+
10
+ import blocklog
11
+ blocklog.init(api_key="blk_...")
12
+
13
+ @blocklog.agent
14
+ def market_analyst():
15
+ ...
16
+
17
+ # With options:
18
+ @blocklog.agent(name="market-analyst", version="2.1", tags=["prod"])
19
+ def market_analyst():
20
+ ...
21
+
22
+ # On a class:
23
+ @blocklog.agent(name="hedge-fund-orchestrator")
24
+ class HedgeFundOrchestrator:
25
+ def run(self):
26
+ ...
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import functools
31
+ import inspect
32
+ import logging
33
+ import traceback as _traceback
34
+ from datetime import datetime, timezone
35
+ from typing import Any, Callable, TypeVar
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ F = TypeVar("F", bound=Callable[..., Any])
40
+
41
+
42
+ def agent(
43
+ func: F | None = None,
44
+ *,
45
+ name: str | None = None,
46
+ version: str = "1.0",
47
+ tags: list[str] | None = None,
48
+ metadata: dict[str, Any] | None = None,
49
+ ) -> F | Callable[[F], F]:
50
+ """Decorator that traces an AI agent function or class.
51
+
52
+ Automatically:
53
+ - Opens a Blocklog ``agent_session`` (sets trace/session context)
54
+ - Emits ``AGENT_START`` event with agent name, version, tags
55
+ - Emits ``AGENT_COMPLETE`` event with duration on clean return
56
+ - Emits ``AGENT_ERROR`` event with traceback on exception
57
+
58
+ Can be used with or without arguments:
59
+
60
+ .. code-block:: python
61
+
62
+ @blocklog.agent
63
+ def my_agent(): ...
64
+
65
+ @blocklog.agent(name="my-agent", version="2.0")
66
+ def my_agent(): ...
67
+
68
+ Parameters
69
+ ----------
70
+ func:
71
+ The function to decorate (when used without arguments).
72
+ name:
73
+ Human-readable agent name. Defaults to ``func.__name__``.
74
+ version:
75
+ Semver-style version string stored in agent metadata.
76
+ tags:
77
+ Optional list of string tags.
78
+ metadata:
79
+ Arbitrary extra data stored in the agent metadata.
80
+
81
+ Returns
82
+ -------
83
+ Callable
84
+ The decorated function (or a decorator if called with arguments).
85
+ """
86
+ def decorator(fn: F) -> F:
87
+ agent_name = name or fn.__name__
88
+ agent_meta = {
89
+ "agent_name": agent_name,
90
+ "agent_version": version,
91
+ "tags": tags or [],
92
+ **(metadata or {}),
93
+ }
94
+
95
+ if inspect.isclass(fn):
96
+ return _wrap_class(fn, agent_name, agent_meta) # type: ignore[return-value]
97
+
98
+ @functools.wraps(fn)
99
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
100
+ return _run_sync(fn, args, kwargs, agent_name, agent_meta)
101
+
102
+ @functools.wraps(fn)
103
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
104
+ return await _run_async(fn, args, kwargs, agent_name, agent_meta)
105
+
106
+ if inspect.iscoroutinefunction(fn):
107
+ return async_wrapper # type: ignore[return-value]
108
+ return sync_wrapper # type: ignore[return-value]
109
+
110
+ # Called as @agent (no args)
111
+ if func is not None:
112
+ return decorator(func)
113
+
114
+ # Called as @agent(...) — return the decorator
115
+ return decorator
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Internal helpers
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def _run_sync(fn: Callable, args: tuple, kwargs: dict, agent_name: str, meta: dict) -> Any:
123
+ from blocklog.context.managers import agent_session
124
+ started_at = _now()
125
+ with agent_session(agent_id=agent_name, source=f"agent:{agent_name}") as ctx:
126
+ _emit("AGENT_START", {
127
+ "agent_name": agent_name,
128
+ "started_at": started_at,
129
+ **meta,
130
+ }, ctx)
131
+ try:
132
+ result = fn(*args, **kwargs)
133
+ _emit("AGENT_COMPLETE", {
134
+ "agent_name": agent_name,
135
+ "duration_ms": _elapsed_ms(started_at),
136
+ "status": "ok",
137
+ }, ctx)
138
+ return result
139
+ except BaseException as exc:
140
+ _emit("AGENT_ERROR", {
141
+ "agent_name": agent_name,
142
+ "duration_ms": _elapsed_ms(started_at),
143
+ "error_type": type(exc).__name__,
144
+ "error_message": str(exc),
145
+ "traceback": _traceback.format_exc(),
146
+ }, ctx)
147
+ raise
148
+
149
+
150
+ async def _run_async(fn: Callable, args: tuple, kwargs: dict, agent_name: str, meta: dict) -> Any:
151
+ from blocklog.context.managers import agent_session
152
+ started_at = _now()
153
+ with agent_session(agent_id=agent_name, source=f"agent:{agent_name}") as ctx:
154
+ _emit("AGENT_START", {
155
+ "agent_name": agent_name,
156
+ "started_at": started_at,
157
+ **meta,
158
+ }, ctx)
159
+ try:
160
+ result = await fn(*args, **kwargs)
161
+ _emit("AGENT_COMPLETE", {
162
+ "agent_name": agent_name,
163
+ "duration_ms": _elapsed_ms(started_at),
164
+ "status": "ok",
165
+ }, ctx)
166
+ return result
167
+ except BaseException as exc:
168
+ _emit("AGENT_ERROR", {
169
+ "agent_name": agent_name,
170
+ "duration_ms": _elapsed_ms(started_at),
171
+ "error_type": type(exc).__name__,
172
+ "error_message": str(exc),
173
+ "traceback": _traceback.format_exc(),
174
+ }, ctx)
175
+ raise
176
+
177
+
178
+ def _wrap_class(cls: type, agent_name: str, meta: dict) -> type:
179
+ """Wrap the ``__init__`` of a class to open an agent session."""
180
+ original_init = cls.__init__
181
+
182
+ @functools.wraps(original_init)
183
+ def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
184
+ from blocklog.context.managers import agent_session
185
+ from blocklog.context.vars import set_context
186
+ from blocklog.models.events import SessionContext
187
+
188
+ ctx = SessionContext(agent_id=agent_name, source=f"agent:{agent_name}")
189
+ set_context(ctx)
190
+ _emit("AGENT_START", {"agent_name": agent_name, **meta}, ctx)
191
+ original_init(self, *args, **kwargs)
192
+
193
+ cls.__init__ = new_init
194
+ return cls
195
+
196
+
197
+ def _emit(event_type: str, payload: dict, ctx: Any) -> None:
198
+ try:
199
+ from blocklog._global import get_client
200
+ client = get_client()
201
+ client.event(
202
+ event_type,
203
+ payload=payload,
204
+ trace_id=str(ctx.trace_id) if ctx else None,
205
+ session_id=str(ctx.session_id) if ctx else None,
206
+ actor_id=ctx.agent_id if ctx else None,
207
+ actor_type="agent",
208
+ )
209
+ except Exception as exc: # noqa: BLE001
210
+ logger.debug("blocklog: emit %s failed: %s", event_type, exc)
211
+
212
+
213
+ def _now() -> str:
214
+ return datetime.now(timezone.utc).isoformat()
215
+
216
+
217
+ def _elapsed_ms(since_iso: str) -> int:
218
+ start = datetime.fromisoformat(since_iso)
219
+ return int((datetime.now(timezone.utc) - start).total_seconds() * 1000)