temporal-parseable
Temporal middleware plugin that ships workflow and activity execution events to Parseable as OpenTelemetry logs and traces.
The plugin emits structured logs (workflow/activity start, complete, fail, retry, duration) into a Parseable log stream, alongside OpenTelemetry traces into a Parseable trace stream. Users get a flat queryable schema for analytics plus a waterfall view of workflow execution.
Installation
pip install temporal-parseable
Quick start
from temporalio.client import Client
from temporalio.worker import Worker
from temporal_parseable import ParseablePlugin, ParseableConfig
config = ParseableConfig(
service_name="my-worker",
endpoint="https://parseable.example.com",
username="admin",
password="secret",
)
plugin = ParseablePlugin(config)
client = await Client.connect("localhost:7233", plugins=[plugin])
async with Worker(
client,
task_queue="my-queue",
workflows=[MyWorkflow],
activities=[my_activity],
plugins=[plugin],
):
await asyncio.Event().wait()
Repository layout
src/temporal_parseable/ # the integration — published as temporal-parseable
├── __init__.py # ParseablePlugin class (extends SimplePlugin)
├── activity_interceptor.py # ActivityInbound interceptor (worker process)
├── workflow_interceptor.py # WorkflowInbound + Outbound interceptors (workflow isolate, replay-safe)
├── workflow.py # public workflow_event() helper — sandbox-safe, no OTel imports
├── exporters.py # OTLP HTTP exporters (logs + traces) + SanitizingSpanExporter
├── _emitter.py # shared ParseableEmitter used by all interceptors
├── _version.py # PLUGIN_VERSION constant
├── config.py # ParseableConfig dataclass with PARSEABLE_* env-var wiring
└── types.py # ParseableEventRecord TypedDict schema
examples/ # runnable demo — not published
├── workflows.py # ExampleWorkflow, FailingWorkflow, UserEventWorkflow, SignalWorkflow,
│ # QueryUpdateWorkflow, ParentWorkflow, ChildWorkflow, ContinueAsNewWorkflow
├── worker.py # demo worker wired with ParseablePlugin
└── client.py # triggers all workflow variants
tests/
├── test_interceptors.py # full interceptor coverage + replay-safety assertion
├── test_sanitizing_exporter.py # unit tests for SanitizingSpanExporter attribute flattening
└── test_config.py # unit tests for ParseableConfig env-var wiring
Architecture
┌───────────────────┐
│ Temporal Server │
│ (localhost:7233) │
└─────────┬─────────┘
│ gRPC
┌───────────────┴───────────────┐
│ Worker │
│ │
│ ┌─────────────────────────┐ │
│ │ Workflow sandbox │ │ ← replay-safe; cannot do I/O
│ │ │ │
│ │ WorkflowInbound + │ │
│ │ WorkflowOutbound │ │
│ │ interceptors │ │
│ │ │ │
│ │ is_replaying() guard │ │
│ └───────────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ ActivityInbound │ │
│ │ interceptor │ │
│ └──────────────┬───────────┘ │
│ │ │
│ ┌──────────────▼───────────┐ │
│ │ ParseableEmitter │ │
│ │ → OTel Logger │ │
│ │ → BatchLogRecordProc │ │
│ │ → OTLPLogExporter │ │
│ └──────────────┬───────────┘ │
│ │ │
│ ┌──────────────┴────────────┐│
│ │ TracerProvider ││
│ │ → BatchSpanProcessor ││
│ │ → SanitizingSpanExporter││
│ │ → OTLPSpanExporter ││
│ └──────────────┬────────────┘│
└─────────────────┼─────────────┘
│ HTTPS
┌─────────▼──────────┐
│ Parseable │
│ /v1/logs (logs) │
│ /v1/traces (spans)│
└────────────────────┘
Key design points
- Replay safety. Workflow events are guarded with
workflow.unsafe.is_replaying(). When Temporal replays a workflow's history (worker crash, cache eviction, or manual replay), the guard skips emission — no duplicate logs or spans. Verified bytests/test_interceptors.py::test_replay_safety. - Sandbox passthrough.
temporal_parseablemust be declared as a passthrough module in theSandboxedWorkflowRunner. This prevents the sandbox from trying to import OTel/requests inside the workflow isolate.workflow.pyis kept sandbox-safe (imports onlytemporalioand stdlib). SanitizingSpanExporter. Temporal's OTel plugin emits spans with nested objects,datetimeinstances, andNonefields as attributes. OTLP attribute values are restricted to primitives, so Parseable's strict OTLP parser rejects the raw payload with400 Invalid data for Value. The sanitizer wraps the trace exporter and flattens nested objects to JSON strings,datetimeto ISO, and dropsNones before serialization.- OTel pinned to 1.x. Temporal's SDK rides the OTel 1.x line. We pin
opentelemetry-sdk>=1.25,<2until Temporal moves to 2.x. X-P-Log-Sourceheaders. Logs are sent withX-P-Log-Source: otel-logsand traces withX-P-Log-Source: otel-traces, as required by Parseable's OTLP ingestor.
Running the demo locally
Prerequisites
- Python 3.9+
- Temporal CLI (
brew install temporalon macOS) - A Parseable instance reachable on the network
Three terminals
Terminal 1 — Temporal dev server:
temporal server start-dev
Runs on localhost:7233 (gRPC) and http://localhost:8233 (UI).
Terminal 2 — Worker:
cd examples
PARSEABLE_URL=https://your-parseable-host \
PARSEABLE_USERNAME=admin \
PARSEABLE_PASSWORD=admin \
python worker.py
PARSEABLE_URL is required. Username/password default to admin/admin if unset (matching a default Parseable dev install). The worker connects to Temporal at localhost:7233 and polls the temporal-parseable-demo task queue.
Terminal 3 — Client (run on demand):
cd examples
python client.py
Triggers happy-path, user-event, parent/child, and failing workflows in sequence.
After running, check Parseable:
- Stream
temporal-logs— workflow/activity records with fieldsworkflow_id,activity_name,attempt,status,duration_ms,service_name, etc. - Stream
temporal-traces— OTel waterfall spans.
Pre-requisite: Create the streams once before first run:
curl -u admin:admin -X PUT https://your-parseable-host/api/v1/logstream/temporal-logs curl -u admin:admin -X PUT https://your-parseable-host/api/v1/logstream/temporal-traces
Configuration
All settings fall back to environment variables with the PARSEABLE_ prefix:
| Argument | Environment variable | Default |
|---|---|---|
endpoint |
PARSEABLE_URL |
http://localhost:8000 |
username |
PARSEABLE_USERNAME |
admin |
password |
PARSEABLE_PASSWORD |
admin |
service_name |
PARSEABLE_SERVICE_NAME |
temporal-worker |
logs.stream |
PARSEABLE_LOGS_STREAM |
temporal-logs |
traces.stream |
PARSEABLE_TRACES_STREAM |
temporal-traces |
Pass logs=None or traces=None to disable either pipeline.
Custom domain events
Emit replay-safe domain events from inside workflow code:
from temporal_parseable.workflow import workflow_event
@workflow.defn
class AgentWorkflow:
@workflow.run
async def run(self, input: AgentInput) -> AgentResult:
workflow_event("agent.started", {"user_id": input.user_id})
plan = await workflow.execute_activity(plan_activity, input)
workflow_event("agent.plan.chosen", {"steps": len(plan.steps)})
for step in plan.steps:
workflow_event("agent.step.start", {"tool": step.tool})
await workflow.execute_activity(run_step, step)
return result
Each call emits a record with type: "user_event", event_name, and event_data. Records are replay-safe — never duplicated during Temporal history replay.
Tests
pip install -e ".[dev]"
pytest # all tests
pytest tests/test_interceptors.py -v # interceptor coverage + replay safety
pytest tests/test_sanitizing_exporter.py -v # SanitizingSpanExporter unit tests
The interceptor test suite exercises every interceptor path and asserts that replay re-emits zero records:
| Test | Effects covered | Live invariants asserted |
|---|---|---|
test_workflow_started_completed |
workflow inbound | 2 workflow records (started + completed) |
test_activity_started_completed |
activity inbound | 2 activity records, attempt=1, duration_ms present |
test_activity_retries_and_failure |
retries | 2 failed records with attempt 1 and 2, error present |
test_signal_inbound |
handle_signal |
2 signal records, direction=inbound |
test_query_inbound |
handle_query |
2 query records |
test_update_inbound |
handle_update |
started + completed records |
test_update_failure |
update ApplicationFailure |
started + failed, no completed |
test_user_events |
workflow_event() |
2 user_event records with correct event_name |
test_child_workflow_outbound |
start_child_workflow |
started + completed, direction=outbound |
test_continue_as_new_outbound |
continue_as_new |
single started record only (no completed) |
test_replay_safety |
all paths | zero records emitted during Replayer.replay_workflow() |
Log schema
| Field | Type | Notes |
|---|---|---|
type |
activity | workflow | user_event | signal | query | update | child_workflow | continue_as_new |
discriminator |
status |
started | completed | failed |
not on user_event |
service_name |
string | from plugin config |
timestamp |
ISO 8601 | event time |
workflow_id |
string | |
run_id |
string | |
workflow_name |
string | |
activity_name |
string | activity records only |
activity_id |
string | activity records only |
attempt |
int | activity records only (1-based) |
duration_ms |
float | on completion/fail |
error |
string | on fail |
direction |
inbound | outbound |
message records |
message_name |
string | signal/query/update name |
target_workflow_id |
string | outbound signals/child workflows |
event_name |
string | user events only |
event_data |
object | user events only |
Caveats
- OTel ecosystem version split. We pin to OTel 1.x because Temporal's SDK does. When Temporal moves to 2.x, we follow.
- Empty-body warning on OTLP success. Parseable returns HTTP 200 with an empty body for accepted OTLP payloads. OTel's deserializer may log a warning about non-compliant response — this is benign.
- Span attribute sanitization.
SanitizingSpanExporteris a workaround for an interop gap between Temporal's OTel instrumentation (emits non-primitive span attributes) and Parseable's strict OTLP parser (requires primitive attribute values). Without it, Parseable returns400 Invalid data for Value. - Throw
ApplicationFailurefor clean handler failures. Signal/update handlers that throw a plainExceptionare treated by Temporal as a workflow-task failure and retried. To fail an update cleanly without retry storms, raiseApplicationFailure("message", non_retryable=True). The interceptor records exactly onefailedevent and the error propagates to the client. child_workflowcompletion is tracked from the child, not the start RPC. The outbound interceptor wraps the result handle sostatus: completed(orfailed) fires when the child actually finishes — not when the start call returns.
Metadata
Release files for temporal-parseable 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| temporal_parseable-0.1.1.tar.gz | 27.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| temporal_parseable-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 49.3 kB
Release files / temporal_parseable-0.1.1.tar.gz
| Download URL | temporal_parseable-0.1.1.tar.gz |
|---|---|
| Size | 27.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fbccc71e589bfeec9416bd00b81eba01ddf585250d63eff81d0f3c375b649971
|
|
BLAKE2b-256 checksum How to use checksums |
a0a945313f597e8e0683b0833cd300df07e1529cad861a95429abecd601a1c51
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.5
|
Release files / temporal_parseable-0.1.1-py3-none-any.whl
| Download URL | temporal_parseable-0.1.1-py3-none-any.whl |
|---|---|
| Size | 22.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4a564020a2d5d6b4a93d07a5c09a4e521b6aca03a2a95234641caf9eaef99dd4
|
|
BLAKE2b-256 checksum How to use checksums |
90a9a5e1287375d9f61d8b2565972ec595bd45b692c80d8501257c00516300aa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.5
|
Security for temporal-parseable 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.