struct-sdk 0.2.33__tar.gz → 0.3.1__tar.gz

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.
@@ -1,3 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: struct-sdk
3
+ Version: 0.3.1
4
+ Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
5
+ Project-URL: Homepage, https://struct.ai
6
+ Project-URL: Documentation, https://struct.ai/docs
7
+ Project-URL: Issues, https://struct.ai/support
8
+ Author-email: Struct <support@struct.ai>
9
+ License: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: agent,ai,anthropic,langchain,observability,openai,opentelemetry,struct,tracing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Monitoring
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: opentelemetry-api>=1.45.1
25
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0
26
+ Requires-Dist: opentelemetry-sdk>=1.27.0
27
+ Provides-Extra: anthropic
28
+ Requires-Dist: anthropic>=0.30.0; extra == 'anthropic'
29
+ Provides-Extra: auto
30
+ Requires-Dist: opentelemetry-instrumentation-aio-pika; extra == 'auto'
31
+ Requires-Dist: opentelemetry-instrumentation-aiohttp-client; extra == 'auto'
32
+ Requires-Dist: opentelemetry-instrumentation-aiohttp-server; extra == 'auto'
33
+ Requires-Dist: opentelemetry-instrumentation-aiokafka; extra == 'auto'
34
+ Requires-Dist: opentelemetry-instrumentation-aiopg; extra == 'auto'
35
+ Requires-Dist: opentelemetry-instrumentation-asyncpg; extra == 'auto'
36
+ Requires-Dist: opentelemetry-instrumentation-botocore; extra == 'auto'
37
+ Requires-Dist: opentelemetry-instrumentation-cassandra; extra == 'auto'
38
+ Requires-Dist: opentelemetry-instrumentation-celery; extra == 'auto'
39
+ Requires-Dist: opentelemetry-instrumentation-confluent-kafka; extra == 'auto'
40
+ Requires-Dist: opentelemetry-instrumentation-django; extra == 'auto'
41
+ Requires-Dist: opentelemetry-instrumentation-falcon; extra == 'auto'
42
+ Requires-Dist: opentelemetry-instrumentation-fastapi; extra == 'auto'
43
+ Requires-Dist: opentelemetry-instrumentation-flask; extra == 'auto'
44
+ Requires-Dist: opentelemetry-instrumentation-grpc; extra == 'auto'
45
+ Requires-Dist: opentelemetry-instrumentation-httpx; extra == 'auto'
46
+ Requires-Dist: opentelemetry-instrumentation-jinja2; extra == 'auto'
47
+ Requires-Dist: opentelemetry-instrumentation-kafka-python; extra == 'auto'
48
+ Requires-Dist: opentelemetry-instrumentation-logging; extra == 'auto'
49
+ Requires-Dist: opentelemetry-instrumentation-mysql; extra == 'auto'
50
+ Requires-Dist: opentelemetry-instrumentation-mysqlclient; extra == 'auto'
51
+ Requires-Dist: opentelemetry-instrumentation-pika; extra == 'auto'
52
+ Requires-Dist: opentelemetry-instrumentation-psycopg; extra == 'auto'
53
+ Requires-Dist: opentelemetry-instrumentation-psycopg2; extra == 'auto'
54
+ Requires-Dist: opentelemetry-instrumentation-pymemcache; extra == 'auto'
55
+ Requires-Dist: opentelemetry-instrumentation-pymongo; extra == 'auto'
56
+ Requires-Dist: opentelemetry-instrumentation-pymssql; extra == 'auto'
57
+ Requires-Dist: opentelemetry-instrumentation-pymysql; extra == 'auto'
58
+ Requires-Dist: opentelemetry-instrumentation-pyramid; extra == 'auto'
59
+ Requires-Dist: opentelemetry-instrumentation-redis; extra == 'auto'
60
+ Requires-Dist: opentelemetry-instrumentation-requests; extra == 'auto'
61
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy; extra == 'auto'
62
+ Requires-Dist: opentelemetry-instrumentation-sqlite3; extra == 'auto'
63
+ Requires-Dist: opentelemetry-instrumentation-starlette; extra == 'auto'
64
+ Requires-Dist: opentelemetry-instrumentation-system-metrics; extra == 'auto'
65
+ Requires-Dist: opentelemetry-instrumentation-threading; extra == 'auto'
66
+ Requires-Dist: opentelemetry-instrumentation-tornado; extra == 'auto'
67
+ Requires-Dist: opentelemetry-instrumentation-tortoiseorm; extra == 'auto'
68
+ Requires-Dist: opentelemetry-instrumentation-urllib; extra == 'auto'
69
+ Requires-Dist: opentelemetry-instrumentation-urllib3; extra == 'auto'
70
+ Requires-Dist: opentelemetry-instrumentation>=0.51b0; extra == 'auto'
71
+ Requires-Dist: opentelemetry-resource-detector-containerid; extra == 'auto'
72
+ Provides-Extra: azure
73
+ Requires-Dist: azure-core-tracing-opentelemetry>=1.0.0b11; extra == 'azure'
74
+ Provides-Extra: claude-agent-sdk
75
+ Requires-Dist: claude-agent-sdk>=0.2.121; extra == 'claude-agent-sdk'
76
+ Requires-Dist: mcp<2,>=1.30.0; extra == 'claude-agent-sdk'
77
+ Provides-Extra: demo
78
+ Requires-Dist: langchain-anthropic>=1.4.6; extra == 'demo'
79
+ Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
80
+ Requires-Dist: langchain-openai>=0.2.0; extra == 'demo'
81
+ Requires-Dist: langchain>=1.3.9; extra == 'demo'
82
+ Requires-Dist: langgraph>=0.2.0; extra == 'demo'
83
+ Requires-Dist: python-dotenv>=1.0.0; extra == 'demo'
84
+ Provides-Extra: dev
85
+ Requires-Dist: mypy>=1.10; extra == 'dev'
86
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
87
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
88
+ Requires-Dist: ruff>=0.5; extra == 'dev'
89
+ Provides-Extra: exceptions
90
+ Requires-Dist: opentelemetry-instrumentation-exceptions; extra == 'exceptions'
91
+ Provides-Extra: langchain
92
+ Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
93
+ Provides-Extra: openai
94
+ Requires-Dist: openai>=1.66.0; extra == 'openai'
95
+ Provides-Extra: structlog
96
+ Requires-Dist: opentelemetry-instrumentation-structlog; extra == 'structlog'
97
+ Provides-Extra: temporal
98
+ Requires-Dist: temporalio>=1.11.0; extra == 'temporal'
99
+ Description-Content-Type: text/markdown
100
+
1
101
  # struct-sdk
2
102
 
3
103
  OpenTelemetry instrumentation for AI agents in Python. Captures spans,
@@ -63,7 +163,7 @@ async with struct.agent(name="checkout"):
63
163
  | `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
64
164
  | `openai` (Responses API) | `chat {model}` | Non-streaming `responses.create` on sync and async clients (Azure clients included). Cached-token accounting from `usage.input_tokens_details`. Streaming and `chat.completions` calls pass through untraced. Message-content capture at parity with the Anthropic integration: per-message events (`gen_ai.system/user/assistant/tool.message`) and `gen_ai.choice`, plus `gen_ai.input.messages` / `gen_ai.output.messages` in span-content mode. Reasoning items are skipped (`encrypted_content` is never emitted). |
65
165
  | `claude_agent_sdk` | `agent`, `chat`, `execute_tool` | Telemetry comes from Claude Code itself in the subprocess; ingest credentials are propagated to it via `ClaudeAgentOptions`. Subagents inherit the configuration. |
66
- | `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
166
+ | `langchain_core` `BaseChatModel` | `chat {model}` | When the underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic`), the provider's span is suppressed, so there is a single span. |
67
167
  | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
68
168
  | `langchain_core` `BaseRetriever` | `retrieval {name}` | |
69
169
  | `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent`, `langchain.agents.create_agent`, and custom graphs. Reads conversation id from any of: `configurable.thread_id` (LangGraph canonical), or `metadata.{thread_id, session_id, conversation_id}` (LangSmith conventions). For multi-turn HTTP-style threading, wrap your entry point in [`struct.agent(session_id=conv_id)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — that's the struct-native replacement for `ls.tracing_context(parent=run_tree)`. |
@@ -255,8 +355,8 @@ async with struct.agent(name="my-agent"):
255
355
  ```
256
356
 
257
357
  When you do use `ChatAnthropic` *and* have the `anthropic` SDK installed,
258
- the chat span comes from the Anthropic instrumentation (single span);
259
- the LangChain layer skips its own to avoid duplicates.
358
+ the chat span comes from the LangChain layer (single span); the Anthropic
359
+ instrumentation skips its own to avoid duplicates.
260
360
 
261
361
  ## Content capture
262
362
 
@@ -268,21 +368,23 @@ from struct_sdk import struct, ContentCaptureMode
268
368
 
269
369
  struct.init(
270
370
  ingest_key=...,
271
- content_capture=ContentCaptureMode.EVENT_ONLY, # default
272
- # or ContentCaptureMode.NONE, SPAN_ONLY, SPAN_AND_EVENT
371
+ content_capture=ContentCaptureMode.EVENT_ONLY, # opt in; the default is NONE
372
+ # or ContentCaptureMode.SPAN_ONLY, SPAN_AND_EVENT
273
373
  )
274
374
  ```
275
375
 
276
- - **`EVENT_ONLY`** (default): per-message content lands on OTel log
376
+ - **`EVENT_ONLY`**: per-message content lands on OTel log
277
377
  records (`gen_ai.{user,assistant,system,tool}.message`,
278
378
  `gen_ai.choice`). Spans carry metadata only.
279
379
  - **`SPAN_ONLY`**: content on span attributes (`gen_ai.input.messages`,
280
380
  `gen_ai.output.messages`).
281
381
  - **`SPAN_AND_EVENT`**: both.
282
- - **`NONE`**: no content captured. Token counts, tool call IDs, finish
283
- reasons, and other metadata still flow.
382
+ - **`NONE`** (default since 0.3.0): no content captured, including the
383
+ user prompt on the agent span and tool arguments and results. Token
384
+ counts, tool call IDs, finish reasons, and other metadata still flow.
284
385
 
285
- `capture_content=False` is a shorthand for `ContentCaptureMode.NONE`.
386
+ `capture_content=False` is a shorthand for `ContentCaptureMode.NONE`, and
387
+ `capture_content=True` for `EVENT_ONLY`.
286
388
 
287
389
  ## Manual scopes
288
390
 
@@ -373,23 +475,175 @@ from `input_tokens`.
373
475
  ```python
374
476
  struct.init(
375
477
  ingest_key="pk-...", # required
376
- service_name="my-agent", # default: "default-agent"
377
- service_version="1.2.3", # default: "0.0.0"
378
- environment="production", # default: "development"
478
+ service_name="my-agent", # default: OTEL_SERVICE_NAME, else "default-agent"
479
+ service_version="1.2.3", # default: from OTEL_RESOURCE_ATTRIBUTES, else "0.0.0"
480
+ environment="production", # default: from OTEL_RESOURCE_ATTRIBUTES, else "development"
379
481
  endpoint="https://ingest.struct.ai", # default; override for self-hosted
380
482
  shutdown_timeout_seconds=5.0, # default: 5.0
381
- content_capture=ContentCaptureMode.EVENT_ONLY,
483
+ content_capture=ContentCaptureMode.EVENT_ONLY, # default: NONE
484
+ auto_instrument=True, # default; False = AI libraries only
485
+ instrumentation_options=None, # per-library full-app settings
486
+ redact_attributes=["*.password"], # default: STRUCT_REDACT_ATTRIBUTES
487
+ )
488
+ ```
489
+
490
+ With `auto_instrument=False` the SDK uses an isolated `TracerProvider` and
491
+ `LoggerProvider` and instruments only the AI libraries; your existing OTel
492
+ setup is unaffected. The default is [full-app mode](#full-app-mode).
493
+
494
+ Service identity follows the standard OpenTelemetry variables when you don't
495
+ pass it: `OTEL_SERVICE_NAME`, and `service.version` and
496
+ `deployment.environment` in `OTEL_RESOURCE_ATTRIBUTES`. Arguments to `init()`
497
+ win. The resource also carries host, runtime and (with the `[auto]` extra)
498
+ container metadata, plus the commit when `STRUCT_GIT_COMMIT_SHA` and
499
+ `STRUCT_GIT_REPOSITORY_URL` are set. It includes a `service.instance.id` so
500
+ replicas stay distinguishable; an ID from `OTEL_RESOURCE_ATTRIBUTES` is
501
+ preserved.
502
+
503
+ In pre-fork servers (for example `gunicorn --preload`), call `struct.init()` in
504
+ each worker (gunicorn's `post_fork` hook), not in the parent: forked workers
505
+ would otherwise share the parent's instance id. A worker that exports after
506
+ being forked from an initialized parent logs one INFO line saying so.
507
+
508
+ ### Redaction
509
+
510
+ `redact_attributes` takes glob patterns, matched case-insensitively. Span,
511
+ span-event and log attributes whose keys match leave the process as
512
+ `[REDACTED]`, for example `["http.request.header.authorization", "*.password"]`.
513
+ `STRUCT_REDACT_ATTRIBUTES` (comma-separated) sets them without code. Only what
514
+ Struct sends is changed; your own exporters still see the real values.
515
+ Metrics, log bodies and span names are not redacted.
516
+
517
+ ## Full-app mode
518
+
519
+ Full-app mode is the default since 0.3.0: `struct.init()` adds APM-style
520
+ telemetry for the whole service, like an OpenTelemetry distro: HTTP servers and
521
+ clients, databases, caches, queues, app logs and process metrics, built on the
522
+ OpenTelemetry contrib instrumentors. Every supported library you have installed
523
+ is instrumented; turn individual ones off below. To keep the 0.2 behavior
524
+ (AI libraries only, isolated providers), pass `auto_instrument=False`.
525
+
526
+ ```bash
527
+ pip install "struct-sdk[auto]" # needs opentelemetry-sdk 1.30+; add [azure] for Azure SDK tracing,
528
+ # [structlog] for structlog (opentelemetry-sdk 1.44+),
529
+ # [exceptions] for uncaught exceptions (1.43+)
530
+ ```
531
+
532
+ ```python
533
+ import fastapi
534
+ from struct_sdk import struct
535
+
536
+ struct.init(ingest_key="pk-...", service_name="checkout-api")
537
+
538
+ app = fastapi.FastAPI() # resolve the class after init()
539
+ ```
540
+
541
+ Framework instrumentors replace the framework's class when `init()` runs, so
542
+ an app class bound earlier stays uninstrumented: `from fastapi import FastAPI`
543
+ above `struct.init()` keeps the original class. Import framework classes after
544
+ `init()`, or reference them through the module as above. For an app created
545
+ before `init()`, call the contrib instrumentor directly (for example
546
+ `FastAPIInstrumentor.instrument_app(app)`). In pre-fork servers, run
547
+ `init()` in each worker, as described above.
548
+
549
+ | Signal | Coverage |
550
+ |---|---|
551
+ | Traces | Django, FastAPI, Flask, Starlette, Tornado, Falcon, Pyramid, aiohttp (client and server), requests, httpx, urllib, urllib3, gRPC, psycopg2, psycopg, aiopg, asyncpg, PyMySQL, MySQL Connector, mysqlclient, pymssql, sqlite3, PyMongo, Cassandra, Redis, pymemcache, Tortoise ORM, Celery, botocore, aiobotocore, Kafka (confluent-kafka, aiokafka, kafka-python), RabbitMQ (pika, aio-pika), Jinja2, Azure SDK. Work handed to threads and `ThreadPoolExecutor` stays in the caller's trace. SQLAlchemy is off by default because its query spans duplicate the database driver's; enable it with `{"sqlalchemy": {"enabled": True}}` if you prefer ORM-level spans. |
552
+ | Logs | Python `logging` records that reach the root logger, with trace context. The SDK's own and OpenTelemetry's records are excluded. With the `[structlog]` extra, structlog events that structlog writes itself (its default) are exported with their fields as attributes; events structlog hands to `logging` (`structlog.stdlib.LoggerFactory`) are exported once, through the `logging` bridge. Extras and structlog fields OpenTelemetry can't take as-is are exported as strings: dicts and mixed-type lists as JSON, everything else (UUIDs, datetimes, `None`, bytes) as its `str()`. Your other handlers still see the original values. With the `[exceptions]` extra, uncaught exceptions (main thread, other threads, asyncio) are exported as log records. If your app configures no logging, its warnings still print to stderr as they would without Struct. This is separate from `content_capture`, which only governs LLM content; disable `{"logging": {"enabled": False}}` and `{"structlog": {"enabled": False}}` (which also stops copies of your logs when Struct attaches to your OpenTelemetry setup), or redact attributes, if application logs can carry sensitive data. |
553
+ | Metrics | Instrumentor metrics (for example `http.server.duration`) and process metrics (`process.cpu.*`, `process.memory.*`, threads, file descriptors, GC). Counters and histograms use delta temporality. |
554
+
555
+ Each instrumentor activates only when its library is installed.
556
+ `instrumentation_options` disables or configures one by name; other keys are
557
+ passed to that instrumentor:
558
+
559
+ ```python
560
+ struct.init(
561
+ ingest_key="pk-...",
562
+ instrumentation_options={
563
+ "fastapi": {"excluded_urls": "health,ready"},
564
+ "logging": {"enabled": False},
565
+ },
566
+ )
567
+ ```
568
+
569
+ As with `opentelemetry-instrument` and the Azure Monitor distro, the standard
570
+ `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS` environment variable also turns
571
+ libraries off (`OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=requests,urllib3`, or
572
+ `*` for all of them). An explicit `instrumentation_options` entry takes
573
+ precedence over it.
574
+
575
+ Full-app mode installs Struct's providers as the global OpenTelemetry
576
+ providers only when no tracer, meter or logger provider is installed. If the
577
+ process already configured any of them (Azure Monitor, your own SDK setup),
578
+ Struct adds its exporters to your SDK tracer and logger providers and
579
+ instruments nothing: your setup keeps ownership of instrumentation, sampling
580
+ and shutdown, and your metrics are not sent to Struct. Struct's own GenAI
581
+ spans keep their isolated provider either way, so your sampler does not drop
582
+ them.
583
+
584
+ ### Datadog ddtrace
585
+
586
+ Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
587
+ `urllib3` beneath `requests` or `botocore`, to avoid duplicate spans and
588
+ conflicting trace headers, and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
589
+ ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
590
+ ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
591
+ mode attaches nothing (an INFO line says so) and only AI spans reach Struct.
592
+
593
+ ### Zero-code activation
594
+
595
+ ```bash
596
+ OTEL_PYTHON_DISTRO=struct \
597
+ STRUCT_INGEST_KEY=pk-... \
598
+ OTEL_SERVICE_NAME=checkout-api \
599
+ opentelemetry-instrument python app.py
600
+ ```
601
+
602
+ `opentelemetry-instrument` comes with `[auto]`. Struct calls `struct.init()`
603
+ before your app starts, with `STRUCT_INGEST_URL` as the endpoint when set.
604
+ Message content stays off; call `struct.init()` in code to turn it on.
605
+ Installing the SDK never changes another distro: Struct's distro is used only
606
+ when `OTEL_PYTHON_DISTRO=struct`. When selected, it also keeps other installed
607
+ configurators (such as `opentelemetry-distro`'s) from installing competing
608
+ providers, unless you set `OTEL_PYTHON_CONFIGURATOR` yourself. If you already run another distro, call
609
+ `struct.init()` in code instead and Struct attaches to its providers.
610
+
611
+ ### Temporal
612
+
613
+ ```bash
614
+ pip install "struct-sdk[temporal]"
615
+ ```
616
+
617
+ ```python
618
+ from temporalio.client import Client
619
+ from temporalio.runtime import Runtime
620
+ from struct_sdk import struct, temporal
621
+
622
+ struct.init(ingest_key="pk-...", service_name="billing-worker")
623
+ runtime = Runtime(telemetry=temporal.telemetry_config(global_tags={"replica": replica_id}))
624
+ client = await Client.connect(
625
+ "temporal:7233",
626
+ runtime=runtime,
627
+ interceptors=[temporal.tracing_interceptor()],
382
628
  )
383
629
  ```
384
630
 
385
- The SDK uses an isolated `TracerProvider` and `LoggerProvider` — your
386
- existing OTel setup is unaffected.
631
+ `telemetry_config()` exports Temporal Core metrics (task slots,
632
+ schedule-to-start latency, poll results, sticky cache) to Struct. It keeps
633
+ Core's console logging at Temporal's defaults, except that failed metric
634
+ exports are not printed. `tracing_interceptor()` (temporalio 1.11+) traces
635
+ workflows and activities with Struct's tracer; a failing span processor never
636
+ blocks a Temporal call or replaces its exception.
637
+ Temporal server metrics are a Prometheus endpoint; scrape them with an
638
+ OpenTelemetry Collector and export OTLP/HTTP to your Struct ingest endpoint
639
+ with the `x-struct-ingest-key` header.
387
640
 
388
641
  ## Reliability
389
642
 
390
643
  The SDK is designed never to break your application. Instrumentation
391
- hooks, span exports, and shutdown all swallow exceptions internally; the
392
- first failure at each site logs at WARN, subsequent ones at DEBUG.
644
+ hooks, span exports, and shutdown all swallow exceptions internally and log
645
+ at DEBUG on the `struct_sdk` logger, with a traceback for the first failure
646
+ at each site.
393
647
  Process exit is bounded by `shutdown_timeout_seconds` and runs in a
394
648
  background thread, so a slow or unreachable ingest endpoint cannot hang
395
649
  shutdown.
@@ -399,13 +653,12 @@ shutdown.
399
653
  - **Spans missing after instrumenting:** Call `struct.init()` *before*
400
654
  importing the instrumented libraries, so the SDK can wire up
401
655
  instrumentation before any instance is constructed.
402
- - **No log records appearing:** Log records only emit when the capture
403
- mode is `EVENT_ONLY` or `SPAN_AND_EVENT` (the default). If you set
404
- `capture_content=False`, content events are disabled.
656
+ - **No message log records appearing:** they only emit when the capture
657
+ mode is `EVENT_ONLY` or `SPAN_AND_EVENT`. The default is `NONE`.
405
658
  - **Duplicate chat spans:** When an LLM provider SDK and a wrapping
406
659
  framework are both instrumented (e.g. `ChatAnthropic` calling through
407
- to `anthropic`), the framework-level chat span is skipped to avoid
408
- duplicates.
660
+ to `anthropic`), the provider-level chat span is skipped and the
661
+ framework's is kept.
409
662
 
410
663
  ## License
411
664
 
@@ -1,52 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: struct-sdk
3
- Version: 0.2.33
4
- Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
5
- Project-URL: Homepage, https://struct.ai
6
- Project-URL: Documentation, https://struct.ai/docs
7
- Project-URL: Issues, https://struct.ai/support
8
- Author-email: Struct <support@struct.ai>
9
- License: Apache-2.0
10
- License-File: LICENSE
11
- Keywords: agent,ai,anthropic,langchain,observability,openai,opentelemetry,struct,tracing
12
- Classifier: Development Status :: 4 - Beta
13
- Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: Apache Software License
15
- Classifier: Operating System :: OS Independent
16
- Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.10
18
- Classifier: Programming Language :: Python :: 3.11
19
- Classifier: Programming Language :: Python :: 3.12
20
- Classifier: Programming Language :: Python :: 3.13
21
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
- Classifier: Topic :: System :: Monitoring
23
- Requires-Python: >=3.10
24
- Requires-Dist: opentelemetry-api>=1.45.1
25
- Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0
26
- Requires-Dist: opentelemetry-sdk>=1.27.0
27
- Provides-Extra: anthropic
28
- Requires-Dist: anthropic>=0.30.0; extra == 'anthropic'
29
- Provides-Extra: claude-agent-sdk
30
- Requires-Dist: claude-agent-sdk>=0.2.121; extra == 'claude-agent-sdk'
31
- Requires-Dist: mcp<2,>=1.30.0; extra == 'claude-agent-sdk'
32
- Provides-Extra: demo
33
- Requires-Dist: langchain-anthropic>=1.4.6; extra == 'demo'
34
- Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
35
- Requires-Dist: langchain-openai>=0.2.0; extra == 'demo'
36
- Requires-Dist: langchain>=1.3.9; extra == 'demo'
37
- Requires-Dist: langgraph>=0.2.0; extra == 'demo'
38
- Requires-Dist: python-dotenv>=1.0.0; extra == 'demo'
39
- Provides-Extra: dev
40
- Requires-Dist: mypy>=1.10; extra == 'dev'
41
- Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
42
- Requires-Dist: pytest>=9.0.3; extra == 'dev'
43
- Requires-Dist: ruff>=0.5; extra == 'dev'
44
- Provides-Extra: langchain
45
- Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
46
- Provides-Extra: openai
47
- Requires-Dist: openai>=1.66.0; extra == 'openai'
48
- Description-Content-Type: text/markdown
49
-
50
1
  # struct-sdk
51
2
 
52
3
  OpenTelemetry instrumentation for AI agents in Python. Captures spans,
@@ -112,7 +63,7 @@ async with struct.agent(name="checkout"):
112
63
  | `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
113
64
  | `openai` (Responses API) | `chat {model}` | Non-streaming `responses.create` on sync and async clients (Azure clients included). Cached-token accounting from `usage.input_tokens_details`. Streaming and `chat.completions` calls pass through untraced. Message-content capture at parity with the Anthropic integration: per-message events (`gen_ai.system/user/assistant/tool.message`) and `gen_ai.choice`, plus `gen_ai.input.messages` / `gen_ai.output.messages` in span-content mode. Reasoning items are skipped (`encrypted_content` is never emitted). |
114
65
  | `claude_agent_sdk` | `agent`, `chat`, `execute_tool` | Telemetry comes from Claude Code itself in the subprocess; ingest credentials are propagated to it via `ClaudeAgentOptions`. Subagents inherit the configuration. |
115
- | `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
66
+ | `langchain_core` `BaseChatModel` | `chat {model}` | When the underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic`), the provider's span is suppressed, so there is a single span. |
116
67
  | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
117
68
  | `langchain_core` `BaseRetriever` | `retrieval {name}` | |
118
69
  | `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent`, `langchain.agents.create_agent`, and custom graphs. Reads conversation id from any of: `configurable.thread_id` (LangGraph canonical), or `metadata.{thread_id, session_id, conversation_id}` (LangSmith conventions). For multi-turn HTTP-style threading, wrap your entry point in [`struct.agent(session_id=conv_id)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — that's the struct-native replacement for `ls.tracing_context(parent=run_tree)`. |
@@ -304,8 +255,8 @@ async with struct.agent(name="my-agent"):
304
255
  ```
305
256
 
306
257
  When you do use `ChatAnthropic` *and* have the `anthropic` SDK installed,
307
- the chat span comes from the Anthropic instrumentation (single span);
308
- the LangChain layer skips its own to avoid duplicates.
258
+ the chat span comes from the LangChain layer (single span); the Anthropic
259
+ instrumentation skips its own to avoid duplicates.
309
260
 
310
261
  ## Content capture
311
262
 
@@ -317,21 +268,23 @@ from struct_sdk import struct, ContentCaptureMode
317
268
 
318
269
  struct.init(
319
270
  ingest_key=...,
320
- content_capture=ContentCaptureMode.EVENT_ONLY, # default
321
- # or ContentCaptureMode.NONE, SPAN_ONLY, SPAN_AND_EVENT
271
+ content_capture=ContentCaptureMode.EVENT_ONLY, # opt in; the default is NONE
272
+ # or ContentCaptureMode.SPAN_ONLY, SPAN_AND_EVENT
322
273
  )
323
274
  ```
324
275
 
325
- - **`EVENT_ONLY`** (default): per-message content lands on OTel log
276
+ - **`EVENT_ONLY`**: per-message content lands on OTel log
326
277
  records (`gen_ai.{user,assistant,system,tool}.message`,
327
278
  `gen_ai.choice`). Spans carry metadata only.
328
279
  - **`SPAN_ONLY`**: content on span attributes (`gen_ai.input.messages`,
329
280
  `gen_ai.output.messages`).
330
281
  - **`SPAN_AND_EVENT`**: both.
331
- - **`NONE`**: no content captured. Token counts, tool call IDs, finish
332
- reasons, and other metadata still flow.
282
+ - **`NONE`** (default since 0.3.0): no content captured, including the
283
+ user prompt on the agent span and tool arguments and results. Token
284
+ counts, tool call IDs, finish reasons, and other metadata still flow.
333
285
 
334
- `capture_content=False` is a shorthand for `ContentCaptureMode.NONE`.
286
+ `capture_content=False` is a shorthand for `ContentCaptureMode.NONE`, and
287
+ `capture_content=True` for `EVENT_ONLY`.
335
288
 
336
289
  ## Manual scopes
337
290
 
@@ -422,23 +375,175 @@ from `input_tokens`.
422
375
  ```python
423
376
  struct.init(
424
377
  ingest_key="pk-...", # required
425
- service_name="my-agent", # default: "default-agent"
426
- service_version="1.2.3", # default: "0.0.0"
427
- environment="production", # default: "development"
378
+ service_name="my-agent", # default: OTEL_SERVICE_NAME, else "default-agent"
379
+ service_version="1.2.3", # default: from OTEL_RESOURCE_ATTRIBUTES, else "0.0.0"
380
+ environment="production", # default: from OTEL_RESOURCE_ATTRIBUTES, else "development"
428
381
  endpoint="https://ingest.struct.ai", # default; override for self-hosted
429
382
  shutdown_timeout_seconds=5.0, # default: 5.0
430
- content_capture=ContentCaptureMode.EVENT_ONLY,
383
+ content_capture=ContentCaptureMode.EVENT_ONLY, # default: NONE
384
+ auto_instrument=True, # default; False = AI libraries only
385
+ instrumentation_options=None, # per-library full-app settings
386
+ redact_attributes=["*.password"], # default: STRUCT_REDACT_ATTRIBUTES
387
+ )
388
+ ```
389
+
390
+ With `auto_instrument=False` the SDK uses an isolated `TracerProvider` and
391
+ `LoggerProvider` and instruments only the AI libraries; your existing OTel
392
+ setup is unaffected. The default is [full-app mode](#full-app-mode).
393
+
394
+ Service identity follows the standard OpenTelemetry variables when you don't
395
+ pass it: `OTEL_SERVICE_NAME`, and `service.version` and
396
+ `deployment.environment` in `OTEL_RESOURCE_ATTRIBUTES`. Arguments to `init()`
397
+ win. The resource also carries host, runtime and (with the `[auto]` extra)
398
+ container metadata, plus the commit when `STRUCT_GIT_COMMIT_SHA` and
399
+ `STRUCT_GIT_REPOSITORY_URL` are set. It includes a `service.instance.id` so
400
+ replicas stay distinguishable; an ID from `OTEL_RESOURCE_ATTRIBUTES` is
401
+ preserved.
402
+
403
+ In pre-fork servers (for example `gunicorn --preload`), call `struct.init()` in
404
+ each worker (gunicorn's `post_fork` hook), not in the parent: forked workers
405
+ would otherwise share the parent's instance id. A worker that exports after
406
+ being forked from an initialized parent logs one INFO line saying so.
407
+
408
+ ### Redaction
409
+
410
+ `redact_attributes` takes glob patterns, matched case-insensitively. Span,
411
+ span-event and log attributes whose keys match leave the process as
412
+ `[REDACTED]`, for example `["http.request.header.authorization", "*.password"]`.
413
+ `STRUCT_REDACT_ATTRIBUTES` (comma-separated) sets them without code. Only what
414
+ Struct sends is changed; your own exporters still see the real values.
415
+ Metrics, log bodies and span names are not redacted.
416
+
417
+ ## Full-app mode
418
+
419
+ Full-app mode is the default since 0.3.0: `struct.init()` adds APM-style
420
+ telemetry for the whole service, like an OpenTelemetry distro: HTTP servers and
421
+ clients, databases, caches, queues, app logs and process metrics, built on the
422
+ OpenTelemetry contrib instrumentors. Every supported library you have installed
423
+ is instrumented; turn individual ones off below. To keep the 0.2 behavior
424
+ (AI libraries only, isolated providers), pass `auto_instrument=False`.
425
+
426
+ ```bash
427
+ pip install "struct-sdk[auto]" # needs opentelemetry-sdk 1.30+; add [azure] for Azure SDK tracing,
428
+ # [structlog] for structlog (opentelemetry-sdk 1.44+),
429
+ # [exceptions] for uncaught exceptions (1.43+)
430
+ ```
431
+
432
+ ```python
433
+ import fastapi
434
+ from struct_sdk import struct
435
+
436
+ struct.init(ingest_key="pk-...", service_name="checkout-api")
437
+
438
+ app = fastapi.FastAPI() # resolve the class after init()
439
+ ```
440
+
441
+ Framework instrumentors replace the framework's class when `init()` runs, so
442
+ an app class bound earlier stays uninstrumented: `from fastapi import FastAPI`
443
+ above `struct.init()` keeps the original class. Import framework classes after
444
+ `init()`, or reference them through the module as above. For an app created
445
+ before `init()`, call the contrib instrumentor directly (for example
446
+ `FastAPIInstrumentor.instrument_app(app)`). In pre-fork servers, run
447
+ `init()` in each worker, as described above.
448
+
449
+ | Signal | Coverage |
450
+ |---|---|
451
+ | Traces | Django, FastAPI, Flask, Starlette, Tornado, Falcon, Pyramid, aiohttp (client and server), requests, httpx, urllib, urllib3, gRPC, psycopg2, psycopg, aiopg, asyncpg, PyMySQL, MySQL Connector, mysqlclient, pymssql, sqlite3, PyMongo, Cassandra, Redis, pymemcache, Tortoise ORM, Celery, botocore, aiobotocore, Kafka (confluent-kafka, aiokafka, kafka-python), RabbitMQ (pika, aio-pika), Jinja2, Azure SDK. Work handed to threads and `ThreadPoolExecutor` stays in the caller's trace. SQLAlchemy is off by default because its query spans duplicate the database driver's; enable it with `{"sqlalchemy": {"enabled": True}}` if you prefer ORM-level spans. |
452
+ | Logs | Python `logging` records that reach the root logger, with trace context. The SDK's own and OpenTelemetry's records are excluded. With the `[structlog]` extra, structlog events that structlog writes itself (its default) are exported with their fields as attributes; events structlog hands to `logging` (`structlog.stdlib.LoggerFactory`) are exported once, through the `logging` bridge. Extras and structlog fields OpenTelemetry can't take as-is are exported as strings: dicts and mixed-type lists as JSON, everything else (UUIDs, datetimes, `None`, bytes) as its `str()`. Your other handlers still see the original values. With the `[exceptions]` extra, uncaught exceptions (main thread, other threads, asyncio) are exported as log records. If your app configures no logging, its warnings still print to stderr as they would without Struct. This is separate from `content_capture`, which only governs LLM content; disable `{"logging": {"enabled": False}}` and `{"structlog": {"enabled": False}}` (which also stops copies of your logs when Struct attaches to your OpenTelemetry setup), or redact attributes, if application logs can carry sensitive data. |
453
+ | Metrics | Instrumentor metrics (for example `http.server.duration`) and process metrics (`process.cpu.*`, `process.memory.*`, threads, file descriptors, GC). Counters and histograms use delta temporality. |
454
+
455
+ Each instrumentor activates only when its library is installed.
456
+ `instrumentation_options` disables or configures one by name; other keys are
457
+ passed to that instrumentor:
458
+
459
+ ```python
460
+ struct.init(
461
+ ingest_key="pk-...",
462
+ instrumentation_options={
463
+ "fastapi": {"excluded_urls": "health,ready"},
464
+ "logging": {"enabled": False},
465
+ },
466
+ )
467
+ ```
468
+
469
+ As with `opentelemetry-instrument` and the Azure Monitor distro, the standard
470
+ `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS` environment variable also turns
471
+ libraries off (`OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=requests,urllib3`, or
472
+ `*` for all of them). An explicit `instrumentation_options` entry takes
473
+ precedence over it.
474
+
475
+ Full-app mode installs Struct's providers as the global OpenTelemetry
476
+ providers only when no tracer, meter or logger provider is installed. If the
477
+ process already configured any of them (Azure Monitor, your own SDK setup),
478
+ Struct adds its exporters to your SDK tracer and logger providers and
479
+ instruments nothing: your setup keeps ownership of instrumentation, sampling
480
+ and shutdown, and your metrics are not sent to Struct. Struct's own GenAI
481
+ spans keep their isolated provider either way, so your sampler does not drop
482
+ them.
483
+
484
+ ### Datadog ddtrace
485
+
486
+ Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
487
+ `urllib3` beneath `requests` or `botocore`, to avoid duplicate spans and
488
+ conflicting trace headers, and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
489
+ ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
490
+ ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
491
+ mode attaches nothing (an INFO line says so) and only AI spans reach Struct.
492
+
493
+ ### Zero-code activation
494
+
495
+ ```bash
496
+ OTEL_PYTHON_DISTRO=struct \
497
+ STRUCT_INGEST_KEY=pk-... \
498
+ OTEL_SERVICE_NAME=checkout-api \
499
+ opentelemetry-instrument python app.py
500
+ ```
501
+
502
+ `opentelemetry-instrument` comes with `[auto]`. Struct calls `struct.init()`
503
+ before your app starts, with `STRUCT_INGEST_URL` as the endpoint when set.
504
+ Message content stays off; call `struct.init()` in code to turn it on.
505
+ Installing the SDK never changes another distro: Struct's distro is used only
506
+ when `OTEL_PYTHON_DISTRO=struct`. When selected, it also keeps other installed
507
+ configurators (such as `opentelemetry-distro`'s) from installing competing
508
+ providers, unless you set `OTEL_PYTHON_CONFIGURATOR` yourself. If you already run another distro, call
509
+ `struct.init()` in code instead and Struct attaches to its providers.
510
+
511
+ ### Temporal
512
+
513
+ ```bash
514
+ pip install "struct-sdk[temporal]"
515
+ ```
516
+
517
+ ```python
518
+ from temporalio.client import Client
519
+ from temporalio.runtime import Runtime
520
+ from struct_sdk import struct, temporal
521
+
522
+ struct.init(ingest_key="pk-...", service_name="billing-worker")
523
+ runtime = Runtime(telemetry=temporal.telemetry_config(global_tags={"replica": replica_id}))
524
+ client = await Client.connect(
525
+ "temporal:7233",
526
+ runtime=runtime,
527
+ interceptors=[temporal.tracing_interceptor()],
431
528
  )
432
529
  ```
433
530
 
434
- The SDK uses an isolated `TracerProvider` and `LoggerProvider` — your
435
- existing OTel setup is unaffected.
531
+ `telemetry_config()` exports Temporal Core metrics (task slots,
532
+ schedule-to-start latency, poll results, sticky cache) to Struct. It keeps
533
+ Core's console logging at Temporal's defaults, except that failed metric
534
+ exports are not printed. `tracing_interceptor()` (temporalio 1.11+) traces
535
+ workflows and activities with Struct's tracer; a failing span processor never
536
+ blocks a Temporal call or replaces its exception.
537
+ Temporal server metrics are a Prometheus endpoint; scrape them with an
538
+ OpenTelemetry Collector and export OTLP/HTTP to your Struct ingest endpoint
539
+ with the `x-struct-ingest-key` header.
436
540
 
437
541
  ## Reliability
438
542
 
439
543
  The SDK is designed never to break your application. Instrumentation
440
- hooks, span exports, and shutdown all swallow exceptions internally; the
441
- first failure at each site logs at WARN, subsequent ones at DEBUG.
544
+ hooks, span exports, and shutdown all swallow exceptions internally and log
545
+ at DEBUG on the `struct_sdk` logger, with a traceback for the first failure
546
+ at each site.
442
547
  Process exit is bounded by `shutdown_timeout_seconds` and runs in a
443
548
  background thread, so a slow or unreachable ingest endpoint cannot hang
444
549
  shutdown.
@@ -448,13 +553,12 @@ shutdown.
448
553
  - **Spans missing after instrumenting:** Call `struct.init()` *before*
449
554
  importing the instrumented libraries, so the SDK can wire up
450
555
  instrumentation before any instance is constructed.
451
- - **No log records appearing:** Log records only emit when the capture
452
- mode is `EVENT_ONLY` or `SPAN_AND_EVENT` (the default). If you set
453
- `capture_content=False`, content events are disabled.
556
+ - **No message log records appearing:** they only emit when the capture
557
+ mode is `EVENT_ONLY` or `SPAN_AND_EVENT`. The default is `NONE`.
454
558
  - **Duplicate chat spans:** When an LLM provider SDK and a wrapping
455
559
  framework are both instrumented (e.g. `ChatAnthropic` calling through
456
- to `anthropic`), the framework-level chat span is skipped to avoid
457
- duplicates.
560
+ to `anthropic`), the provider-level chat span is skipped and the
561
+ framework's is kept.
458
562
 
459
563
  ## License
460
564