temporal-parseable 0.1.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.
- temporal_parseable/__init__.py +162 -0
- temporal_parseable/_emitter.py +83 -0
- temporal_parseable/_version.py +1 -0
- temporal_parseable/activity_interceptor.py +91 -0
- temporal_parseable/config.py +116 -0
- temporal_parseable/exporters.py +121 -0
- temporal_parseable/types.py +70 -0
- temporal_parseable/workflow.py +82 -0
- temporal_parseable/workflow_interceptor.py +411 -0
- temporal_parseable-0.1.0.dist-info/METADATA +322 -0
- temporal_parseable-0.1.0.dist-info/RECORD +13 -0
- temporal_parseable-0.1.0.dist-info/WHEEL +4 -0
- temporal_parseable-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
temporal-parseable
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Temporal plugin that ships workflow and activity execution events to Parseable
|
|
6
|
+
as OpenTelemetry structured logs and traces.
|
|
7
|
+
|
|
8
|
+
Quick start — same pattern as any Temporal plugin::
|
|
9
|
+
|
|
10
|
+
from temporalio.client import Client
|
|
11
|
+
from temporalio.worker import Worker
|
|
12
|
+
from temporal_parseable import ParseablePlugin, ParseableConfig
|
|
13
|
+
|
|
14
|
+
config = ParseableConfig(
|
|
15
|
+
service_name="my-worker",
|
|
16
|
+
endpoint="https://parseable.example.com",
|
|
17
|
+
username="admin",
|
|
18
|
+
password="secret",
|
|
19
|
+
)
|
|
20
|
+
plugin = ParseablePlugin(config)
|
|
21
|
+
|
|
22
|
+
# Add to client for span context propagation (links client → workflow traces)
|
|
23
|
+
client = await Client.connect("localhost:7233", plugins=[plugin])
|
|
24
|
+
|
|
25
|
+
# Add to worker for activity + workflow interception
|
|
26
|
+
# No SandboxedWorkflowRunner needed — the plugin handles it automatically
|
|
27
|
+
async with Worker(
|
|
28
|
+
client,
|
|
29
|
+
task_queue="my-queue",
|
|
30
|
+
workflows=[MyWorkflow],
|
|
31
|
+
activities=[my_activity],
|
|
32
|
+
plugins=[plugin],
|
|
33
|
+
):
|
|
34
|
+
await asyncio.Event().wait()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from typing import Optional, Type
|
|
40
|
+
|
|
41
|
+
from temporalio.plugin import SimplePlugin
|
|
42
|
+
from temporalio.worker import Interceptor, WorkflowInterceptorClassInput
|
|
43
|
+
from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions
|
|
44
|
+
|
|
45
|
+
from .config import ParseableConfig, LogsConfig, TracesConfig
|
|
46
|
+
from .exporters import build_tracer_provider, build_logger_provider
|
|
47
|
+
from ._emitter import ParseableEmitter
|
|
48
|
+
from .activity_interceptor import ParseableActivityInterceptor
|
|
49
|
+
from .workflow_interceptor import (
|
|
50
|
+
ParseableWorkflowInboundInterceptor,
|
|
51
|
+
ParseableWorkflowOutboundInterceptor,
|
|
52
|
+
)
|
|
53
|
+
from . import workflow as _workflow_module
|
|
54
|
+
from ._version import PLUGIN_VERSION
|
|
55
|
+
|
|
56
|
+
__version__ = PLUGIN_VERSION
|
|
57
|
+
__all__ = [
|
|
58
|
+
"ParseablePlugin",
|
|
59
|
+
"ParseableConfig",
|
|
60
|
+
"LogsConfig",
|
|
61
|
+
"TracesConfig",
|
|
62
|
+
"PLUGIN_VERSION",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_sandbox() -> SandboxedWorkflowRunner:
|
|
67
|
+
"""
|
|
68
|
+
Build a SandboxedWorkflowRunner with temporal_parseable marked as passthrough.
|
|
69
|
+
|
|
70
|
+
Without this, the Temporal workflow sandbox tries to import OTel/requests
|
|
71
|
+
inside the isolate and raises RestrictedWorkflowAccessError. Injecting the
|
|
72
|
+
runner via SimplePlugin means users never need to configure this themselves.
|
|
73
|
+
"""
|
|
74
|
+
return SandboxedWorkflowRunner(
|
|
75
|
+
restrictions=SandboxRestrictions.default.with_passthrough_modules(
|
|
76
|
+
"temporal_parseable"
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ParseablePlugin(SimplePlugin):
|
|
82
|
+
"""
|
|
83
|
+
Temporal plugin that ships workflow and activity events to Parseable.
|
|
84
|
+
|
|
85
|
+
Pass a single instance to both Client.connect and Worker — the plugin
|
|
86
|
+
is safe to reuse across both::
|
|
87
|
+
|
|
88
|
+
plugin = ParseablePlugin(ParseableConfig())
|
|
89
|
+
|
|
90
|
+
client = await Client.connect("localhost:7233", plugins=[plugin])
|
|
91
|
+
|
|
92
|
+
async with Worker(client, task_queue="q", workflows=[W], plugins=[plugin]):
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
The plugin automatically:
|
|
96
|
+
- Configures the workflow sandbox passthrough (no SandboxedWorkflowRunner needed)
|
|
97
|
+
- Wires activity and workflow interceptors
|
|
98
|
+
- Sets up OTel log and trace pipelines to Parseable
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, config: Optional[ParseableConfig] = None) -> None:
|
|
102
|
+
self._config = config or ParseableConfig()
|
|
103
|
+
|
|
104
|
+
# Build OTel providers
|
|
105
|
+
self._tracer_provider = build_tracer_provider(self._config)
|
|
106
|
+
self._logger_provider = build_logger_provider(self._config)
|
|
107
|
+
|
|
108
|
+
# Shared emitter used by all interceptors
|
|
109
|
+
self._emitter = ParseableEmitter(
|
|
110
|
+
logger_provider=self._logger_provider,
|
|
111
|
+
service_name=self._config.service_name,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Make the emitter available to workflow_event()
|
|
115
|
+
_workflow_module._set_emitter(self._emitter)
|
|
116
|
+
|
|
117
|
+
worker_interceptor = _ParseableWorkerInterceptor(self._emitter)
|
|
118
|
+
|
|
119
|
+
super().__init__(
|
|
120
|
+
name="parseable.temporal",
|
|
121
|
+
interceptors=[worker_interceptor],
|
|
122
|
+
# Inject sandbox passthrough automatically — users don't need to
|
|
123
|
+
# configure SandboxedWorkflowRunner manually.
|
|
124
|
+
workflow_runner=_build_sandbox(),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def config(self) -> ParseableConfig:
|
|
129
|
+
return self._config
|
|
130
|
+
|
|
131
|
+
def shutdown(self) -> None:
|
|
132
|
+
"""Flush and shut down OTel providers. Call on clean worker exit."""
|
|
133
|
+
if self._tracer_provider:
|
|
134
|
+
self._tracer_provider.shutdown()
|
|
135
|
+
if self._logger_provider:
|
|
136
|
+
self._logger_provider.shutdown()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _ParseableWorkerInterceptor(Interceptor):
|
|
140
|
+
"""
|
|
141
|
+
Worker-level interceptor that wires activity and workflow interceptors.
|
|
142
|
+
One instance lives on the worker per plugin instance.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, emitter: ParseableEmitter) -> None:
|
|
146
|
+
self._emitter = emitter
|
|
147
|
+
|
|
148
|
+
def intercept_activity(self, next): # type: ignore[override]
|
|
149
|
+
return ParseableActivityInterceptor(next, self._emitter)
|
|
150
|
+
|
|
151
|
+
def workflow_interceptor_class(
|
|
152
|
+
self, input: WorkflowInterceptorClassInput
|
|
153
|
+
) -> Type[ParseableWorkflowInboundInterceptor]:
|
|
154
|
+
emitter = self._emitter
|
|
155
|
+
|
|
156
|
+
class _Injected(ParseableWorkflowInboundInterceptor):
|
|
157
|
+
# emitter is set in init() AFTER _outbound is created, not in __init__
|
|
158
|
+
def init(self, outbound): # type: ignore[override]
|
|
159
|
+
super().init(outbound)
|
|
160
|
+
self._set_emitter(emitter)
|
|
161
|
+
|
|
162
|
+
return _Injected
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal log record emitter.
|
|
3
|
+
|
|
4
|
+
All interceptors share a single ParseableEmitter instance (held on the
|
|
5
|
+
ParseablePlugin) which serialises ParseableEventRecord dicts and forwards
|
|
6
|
+
them to the OTel LoggerProvider.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
17
|
+
from opentelemetry._logs.severity import SeverityNumber
|
|
18
|
+
|
|
19
|
+
from .types import ParseableEventRecord
|
|
20
|
+
from ._version import PLUGIN_VERSION
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ParseableEmitter:
|
|
24
|
+
"""
|
|
25
|
+
Thread-safe (asyncio-safe) emitter for structured Parseable log records.
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
emitter = ParseableEmitter(logger_provider, service_name="my-worker")
|
|
30
|
+
emitter.emit({"type": "workflow", "status": "started", ...})
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
logger_provider: Optional[LoggerProvider],
|
|
36
|
+
service_name: str,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._service_name = service_name
|
|
39
|
+
self._plugin_version = PLUGIN_VERSION
|
|
40
|
+
if logger_provider is not None:
|
|
41
|
+
self._logger = logger_provider.get_logger(
|
|
42
|
+
"temporal_parseable",
|
|
43
|
+
schema_url="https://parseable.com/temporal/schema/v1",
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
self._logger = None
|
|
47
|
+
|
|
48
|
+
def emit(self, record: ParseableEventRecord) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Emit a single record to Parseable.
|
|
51
|
+
|
|
52
|
+
Adds service_name, timestamp, and plugin_version if not already
|
|
53
|
+
present, then serialises to JSON and sends via the OTel logger.
|
|
54
|
+
Silently no-ops when logs are disabled (logger is None).
|
|
55
|
+
"""
|
|
56
|
+
if self._logger is None:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
record.setdefault("service_name", self._service_name) # type: ignore[misc]
|
|
60
|
+
record.setdefault("timestamp", _now_iso()) # type: ignore[misc]
|
|
61
|
+
record.setdefault("plugin_version", self._plugin_version) # type: ignore[misc]
|
|
62
|
+
|
|
63
|
+
body = json.dumps(record, default=str)
|
|
64
|
+
|
|
65
|
+
self._logger.emit(
|
|
66
|
+
timestamp=_now_ns(),
|
|
67
|
+
observed_timestamp=_now_ns(),
|
|
68
|
+
severity_number=SeverityNumber.INFO,
|
|
69
|
+
severity_text="INFO",
|
|
70
|
+
body=body,
|
|
71
|
+
attributes={"parseable.stream": "temporal-logs"},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def _now_iso() -> str:
|
|
78
|
+
return datetime.now(timezone.utc).isoformat()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _now_ns() -> int:
|
|
82
|
+
"""Current time as nanoseconds since epoch (required by OTel APIs)."""
|
|
83
|
+
return time.time_ns()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PLUGIN_VERSION = "0.1.0"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Activity interceptor.
|
|
3
|
+
|
|
4
|
+
Wraps every activity execution and emits three possible records to Parseable:
|
|
5
|
+
|
|
6
|
+
started — before the activity function runs
|
|
7
|
+
completed — after successful return
|
|
8
|
+
failed — after an exception (including ApplicationError retries)
|
|
9
|
+
|
|
10
|
+
Fields captured on every record:
|
|
11
|
+
|
|
12
|
+
type = "activity"
|
|
13
|
+
activity_name — activity function name
|
|
14
|
+
activity_id — unique ID assigned by Temporal
|
|
15
|
+
attempt — 1-based retry attempt number
|
|
16
|
+
workflow_id — parent workflow
|
|
17
|
+
run_id — parent run
|
|
18
|
+
workflow_name — parent workflow type name
|
|
19
|
+
duration_ms — wall-clock ms from started to completed/failed
|
|
20
|
+
error — stringified exception on failed records
|
|
21
|
+
|
|
22
|
+
Mirrors the TypeScript activity-interceptor.ts.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from temporalio import activity
|
|
31
|
+
from temporalio.worker import ActivityInboundInterceptor, ExecuteActivityInput
|
|
32
|
+
|
|
33
|
+
from ._emitter import ParseableEmitter, _now_iso
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ParseableActivityInterceptor(ActivityInboundInterceptor):
|
|
37
|
+
"""
|
|
38
|
+
Inbound activity interceptor that emits structured records to Parseable.
|
|
39
|
+
|
|
40
|
+
One instance is created per activity execution by
|
|
41
|
+
``ParseableWorkerInterceptor.intercept_activity``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
next: ActivityInboundInterceptor,
|
|
47
|
+
emitter: ParseableEmitter,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__(next)
|
|
50
|
+
self._emitter = emitter
|
|
51
|
+
|
|
52
|
+
async def execute_activity(self, input: ExecuteActivityInput) -> Any:
|
|
53
|
+
info = activity.info()
|
|
54
|
+
|
|
55
|
+
base = {
|
|
56
|
+
"type": "activity",
|
|
57
|
+
"activity_name": info.activity_type,
|
|
58
|
+
"activity_id": info.activity_id,
|
|
59
|
+
"attempt": info.attempt,
|
|
60
|
+
"workflow_id": info.workflow_id,
|
|
61
|
+
"run_id": info.workflow_run_id,
|
|
62
|
+
"workflow_name": info.workflow_type,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# ── started ──────────────────────────────────────────────────────────
|
|
66
|
+
self._emitter.emit({**base, "status": "started", "timestamp": _now_iso()}) # type: ignore[arg-type]
|
|
67
|
+
|
|
68
|
+
start_ns = time.monotonic_ns()
|
|
69
|
+
try:
|
|
70
|
+
result = await self.next.execute_activity(input)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
73
|
+
# ── failed ───────────────────────────────────────────────────────
|
|
74
|
+
self._emitter.emit({ # type: ignore[arg-type]
|
|
75
|
+
**base,
|
|
76
|
+
"status": "failed",
|
|
77
|
+
"timestamp": _now_iso(),
|
|
78
|
+
"duration_ms": round(duration_ms, 3),
|
|
79
|
+
"error": str(exc),
|
|
80
|
+
})
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
84
|
+
# ── completed ────────────────────────────────────────────────────────
|
|
85
|
+
self._emitter.emit({ # type: ignore[arg-type]
|
|
86
|
+
**base,
|
|
87
|
+
"status": "completed",
|
|
88
|
+
"timestamp": _now_iso(),
|
|
89
|
+
"duration_ms": round(duration_ms, 3),
|
|
90
|
+
})
|
|
91
|
+
return result
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration for the Parseable Temporal plugin.
|
|
3
|
+
|
|
4
|
+
All settings are readable from environment variables with the PARSEABLE_
|
|
5
|
+
prefix, matching the TypeScript plugin's env-var convention. Values passed
|
|
6
|
+
directly to ParseableConfig take precedence over environment variables.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _env(key: str, default: str) -> str:
|
|
17
|
+
return os.environ.get(key, default)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class LogsConfig:
|
|
22
|
+
"""Configuration for the structured-log pipeline."""
|
|
23
|
+
|
|
24
|
+
#: Parseable stream name for log records.
|
|
25
|
+
stream: str = field(default_factory=lambda: _env("PARSEABLE_LOGS_STREAM", "temporal-logs"))
|
|
26
|
+
enabled: bool = field(
|
|
27
|
+
default_factory=lambda: _env("PARSEABLE_ENABLE_LOGS", "true").lower() != "false"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TracesConfig:
|
|
33
|
+
"""Configuration for the OTel trace pipeline."""
|
|
34
|
+
|
|
35
|
+
#: Parseable stream name for trace spans.
|
|
36
|
+
stream: str = field(
|
|
37
|
+
default_factory=lambda: _env("PARSEABLE_TRACES_STREAM", "temporal-traces")
|
|
38
|
+
)
|
|
39
|
+
enabled: bool = field(
|
|
40
|
+
default_factory=lambda: _env("PARSEABLE_ENABLE_TRACES", "true").lower() != "false"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ParseableConfig:
|
|
46
|
+
"""
|
|
47
|
+
Full configuration for ParseablePlugin.
|
|
48
|
+
|
|
49
|
+
Usage::
|
|
50
|
+
|
|
51
|
+
config = ParseableConfig(
|
|
52
|
+
service_name="my-worker",
|
|
53
|
+
endpoint="https://parseable.example.com",
|
|
54
|
+
username="admin",
|
|
55
|
+
password="secret",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
All arguments fall back to environment variables when omitted:
|
|
59
|
+
|
|
60
|
+
===================== =========================== =======================
|
|
61
|
+
Argument Environment variable Default
|
|
62
|
+
===================== =========================== =======================
|
|
63
|
+
endpoint PARSEABLE_URL http://localhost:8000
|
|
64
|
+
username PARSEABLE_USERNAME admin
|
|
65
|
+
password PARSEABLE_PASSWORD admin
|
|
66
|
+
service_name PARSEABLE_SERVICE_NAME temporal-worker
|
|
67
|
+
===================== =========================== =======================
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
#: Parseable base URL, e.g. ``http://parseable.example:8000``.
|
|
71
|
+
#: Logs are POSTed to ``{endpoint}/v1/logs``,
|
|
72
|
+
#: traces to ``{endpoint}/v1/traces``.
|
|
73
|
+
endpoint: str = field(
|
|
74
|
+
default_factory=lambda: _env("PARSEABLE_URL", "http://localhost:8000")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
#: HTTP Basic auth username.
|
|
78
|
+
username: str = field(
|
|
79
|
+
default_factory=lambda: _env("PARSEABLE_USERNAME", "admin")
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#: HTTP Basic auth password.
|
|
83
|
+
password: str = field(
|
|
84
|
+
default_factory=lambda: _env("PARSEABLE_PASSWORD", "admin")
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
#: Becomes the ``service.name`` OTel resource attribute and the
|
|
88
|
+
#: ``service_name`` field in every log record.
|
|
89
|
+
service_name: str = field(
|
|
90
|
+
default_factory=lambda: _env("PARSEABLE_SERVICE_NAME", "temporal-worker")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
#: Structured-log pipeline config. Pass ``logs=None`` to disable logs.
|
|
94
|
+
logs: Optional[LogsConfig] = field(default_factory=LogsConfig)
|
|
95
|
+
|
|
96
|
+
#: OTel trace pipeline config. Pass ``traces=None`` to disable traces.
|
|
97
|
+
traces: Optional[TracesConfig] = field(default_factory=TracesConfig)
|
|
98
|
+
|
|
99
|
+
# ── Derived helpers ──────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def logs_endpoint(self) -> str:
|
|
103
|
+
"""Full OTLP/HTTP logs endpoint."""
|
|
104
|
+
return f"{self.endpoint.rstrip('/')}/v1/logs"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def traces_endpoint(self) -> str:
|
|
108
|
+
"""Full OTLP/HTTP traces endpoint."""
|
|
109
|
+
return f"{self.endpoint.rstrip('/')}/v1/traces"
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def auth_header(self) -> str:
|
|
113
|
+
"""Base64-encoded HTTP Basic auth header value."""
|
|
114
|
+
import base64
|
|
115
|
+
token = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
|
|
116
|
+
return f"Basic {token}"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Dict, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
9
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
10
|
+
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
|
|
11
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
12
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
13
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
14
|
+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
|
15
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
16
|
+
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
|
17
|
+
|
|
18
|
+
from .config import ParseableConfig
|
|
19
|
+
from ._version import PLUGIN_VERSION
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_Primitive = (str, int, float, bool)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sanitize_value(v: Any) -> Any:
|
|
27
|
+
if v is None:
|
|
28
|
+
return None
|
|
29
|
+
if isinstance(v, _Primitive):
|
|
30
|
+
return v
|
|
31
|
+
if isinstance(v, datetime):
|
|
32
|
+
return v.isoformat()
|
|
33
|
+
if isinstance(v, (list, tuple)):
|
|
34
|
+
sanitised = [_sanitize_value(item) for item in v]
|
|
35
|
+
cleaned = [item for item in sanitised if item is not None]
|
|
36
|
+
if all(isinstance(item, _Primitive) for item in cleaned):
|
|
37
|
+
return cleaned
|
|
38
|
+
return json.dumps(cleaned)
|
|
39
|
+
if isinstance(v, dict):
|
|
40
|
+
return json.dumps(v, default=str)
|
|
41
|
+
return str(v)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _sanitize_span(span: ReadableSpan) -> ReadableSpan:
|
|
45
|
+
if not span.attributes:
|
|
46
|
+
return span
|
|
47
|
+
clean: Dict[str, Any] = {}
|
|
48
|
+
for key, value in span.attributes.items():
|
|
49
|
+
sanitised = _sanitize_value(value)
|
|
50
|
+
if sanitised is not None:
|
|
51
|
+
clean[key] = sanitised
|
|
52
|
+
try:
|
|
53
|
+
object.__setattr__(span, "_attributes", clean)
|
|
54
|
+
except (AttributeError, TypeError):
|
|
55
|
+
pass
|
|
56
|
+
return span
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SanitizingSpanExporter(SpanExporter):
|
|
60
|
+
def __init__(self, delegate: SpanExporter) -> None:
|
|
61
|
+
self._delegate = delegate
|
|
62
|
+
|
|
63
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
64
|
+
sanitised = [_sanitize_span(span) for span in spans]
|
|
65
|
+
return self._delegate.export(sanitised)
|
|
66
|
+
|
|
67
|
+
def shutdown(self) -> None:
|
|
68
|
+
self._delegate.shutdown()
|
|
69
|
+
|
|
70
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
71
|
+
return self._delegate.force_flush(timeout_millis)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resource(config: ParseableConfig) -> Resource:
|
|
75
|
+
return Resource.create({
|
|
76
|
+
SERVICE_NAME: config.service_name,
|
|
77
|
+
"parseable.plugin.version": PLUGIN_VERSION,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_tracer_provider(config: ParseableConfig) -> Optional[TracerProvider]:
|
|
82
|
+
if config.traces is None or not config.traces.enabled:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
endpoint = config.traces_endpoint
|
|
86
|
+
stream = config.traces.stream
|
|
87
|
+
logger.info("Parseable traces endpoint: %s (stream=%s)", endpoint, stream)
|
|
88
|
+
|
|
89
|
+
otlp_exporter = OTLPSpanExporter(
|
|
90
|
+
endpoint=endpoint,
|
|
91
|
+
headers={
|
|
92
|
+
"Authorization": config.auth_header,
|
|
93
|
+
"X-P-Stream": stream,
|
|
94
|
+
"X-P-Log-Source": "otel-traces", # tells Parseable this is OTLP traces
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
sanitizing_exporter = SanitizingSpanExporter(otlp_exporter)
|
|
98
|
+
provider = TracerProvider(resource=_resource(config))
|
|
99
|
+
provider.add_span_processor(BatchSpanProcessor(sanitizing_exporter))
|
|
100
|
+
return provider
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_logger_provider(config: ParseableConfig) -> Optional[LoggerProvider]:
|
|
104
|
+
if config.logs is None or not config.logs.enabled:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
endpoint = config.logs_endpoint
|
|
108
|
+
stream = config.logs.stream
|
|
109
|
+
logger.info("Parseable logs endpoint: %s (stream=%s)", endpoint, stream)
|
|
110
|
+
|
|
111
|
+
otlp_log_exporter = OTLPLogExporter(
|
|
112
|
+
endpoint=endpoint,
|
|
113
|
+
headers={
|
|
114
|
+
"Authorization": config.auth_header,
|
|
115
|
+
"X-P-Stream": stream,
|
|
116
|
+
"X-P-Log-Source": "otel-logs", # tells Parseable this is OTLP logs
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
provider = LoggerProvider(resource=_resource(config))
|
|
120
|
+
provider.add_log_record_processor(BatchLogRecordProcessor(otlp_log_exporter))
|
|
121
|
+
return provider
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parseable event record schema.
|
|
3
|
+
|
|
4
|
+
Mirrors the TypeScript ParseableEventRecord type from types.ts exactly.
|
|
5
|
+
Every field that can appear in a log line sent to the temporal-logs stream
|
|
6
|
+
is declared here. Optional fields are absent from the dict when not set —
|
|
7
|
+
callers use TypedDict with total=False sections for optional keys.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Literal, Optional
|
|
13
|
+
from typing_extensions import TypedDict, Required
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
EventType = Literal[
|
|
17
|
+
"activity",
|
|
18
|
+
"workflow",
|
|
19
|
+
"user_event",
|
|
20
|
+
"signal",
|
|
21
|
+
"query",
|
|
22
|
+
"update",
|
|
23
|
+
"child_workflow",
|
|
24
|
+
"continue_as_new",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
EventStatus = Literal["started", "completed", "failed"]
|
|
28
|
+
EventDirection = Literal["inbound", "outbound"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ParseableEventRecord(TypedDict, total=False):
|
|
32
|
+
"""
|
|
33
|
+
Flat log record emitted to the Parseable temporal-logs stream.
|
|
34
|
+
|
|
35
|
+
Required fields are always present. Optional fields are included only when
|
|
36
|
+
relevant to the event type (e.g. activity_name is set for activity records,
|
|
37
|
+
event_name for user_event records, etc.).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# ── Required on every record ────────────────────────────────────────────
|
|
41
|
+
type: Required[EventType]
|
|
42
|
+
service_name: Required[str]
|
|
43
|
+
timestamp: Required[str] # ISO 8601
|
|
44
|
+
workflow_id: Required[str]
|
|
45
|
+
run_id: Required[str]
|
|
46
|
+
workflow_name: Required[str]
|
|
47
|
+
|
|
48
|
+
# ── Present on all records except user_event ────────────────────────────
|
|
49
|
+
status: EventStatus
|
|
50
|
+
|
|
51
|
+
# ── Activity records only ───────────────────────────────────────────────
|
|
52
|
+
activity_name: str
|
|
53
|
+
activity_id: str
|
|
54
|
+
attempt: int # 1-based
|
|
55
|
+
|
|
56
|
+
# ── Completion / failure fields ─────────────────────────────────────────
|
|
57
|
+
duration_ms: float
|
|
58
|
+
error: str # failure message
|
|
59
|
+
|
|
60
|
+
# ── Message records (signal/query/update/child_workflow/continue_as_new) ─
|
|
61
|
+
direction: EventDirection
|
|
62
|
+
message_name: str # signal/query/update name or child type
|
|
63
|
+
target_workflow_id: str # outbound signals/child workflows
|
|
64
|
+
|
|
65
|
+
# ── User-event records only ─────────────────────────────────────────────
|
|
66
|
+
event_name: str
|
|
67
|
+
event_data: Any # arbitrary JSON-serialisable payload
|
|
68
|
+
|
|
69
|
+
# ── Plugin metadata ─────────────────────────────────────────────────────
|
|
70
|
+
plugin_version: str
|