{"slug": "e2b-sandbox-monitoring-tracing-with-opentelemetry", "title": "E2B Sandbox Monitoring & Tracing with OpenTelemetry", "summary": "E2B sandboxes can be monitored and traced with OpenTelemetry by instrumenting the application that creates them, according to a technical guide from SigNoz. The guide details five telemetry paths — application instrumentation, in-sandbox code instrumentation, sandbox resource metrics, sandbox lifecycle events, and E2B's own OTel export — all exporting to the same SigNoz endpoint, with the first path requiring Python 3.10 or later and the e2b-code-interpreter package. The E2B OTel telemetry export path, which provides e2b.* metrics and service_name: e2b logs with no code, requires an Enterprise plan and an onboarding request.", "body_md": "## Overview\n\n[E2B](https://e2b.dev/) runs AI-generated code in an isolated Linux microVM. Your agent creates a sandbox, runs code in it, reads the output, and kills it. Two things go wrong in that loop, and each needs its own telemetry: the code inside the sandbox fails or hangs, and the sandbox lifecycle itself turns slow or errors out. A sandbox is ephemeral, so the execution that failed is gone before you can open a terminal, and nothing records how it exited until you instrument it. This page covers both halves. You end up with the execution that failed and the reason, the share of request latency that came from E2B rather than your own code, and the sandboxes that stayed alive long enough to matter on a per-second bill.\n\n## Prerequisites\n\n- An instance of SigNoz (either [Cloud](https://signoz.io/teams/) or[Self-Hosted](https://signoz.io/docs/install/self-host/) )\n- Python 3.10 or later. The `e2b` SDK requires 3.10.\n- An E2B API key from the [E2B Dashboard](https://e2b.dev/dashboard) . Every path on this page except the last one works on the free Hobby plan.\n\n## How it works\n\nE2B produces telemetry through five paths. They do not overlap, so pick the ones that answer your question.\n\n| Path | What you get | What it costs you | \n|---|---|---|\n| Instrument the application that creates sandboxes | One trace per session, a span per execution, exit codes, and errors | A wrapper around `Sandbox.create` ,`run_code` , and`commands.run` | \n| Instrument the code that runs inside the sandbox | Spans and logs from the generated code, joined to the same trace | An OpenTelemetry SDK shipped into the sandbox | \n| Sandbox resource metrics | CPU, memory, and disk per sandbox | A poller over `get_metrics()` | \n| Sandbox lifecycle events | Create, update, and kill events as log records | A poller over the events API, or a webhook receiver | \n| E2B OTel telemetry export | `e2b.*` metrics and`service_name: e2b` logs, with no code at all | An Enterprise plan and an onboarding request | \n\nStart with the first path. It covers every sandbox your application creates, including the ones whose code a model wrote. The SDK also reports some HTTP telemetry by itself, with no code and no instrumentation package, which [What the SDK Emits on Its Own](#what-the-sdk-emits-on-its-own) covers. The rest sit under [Optional Setups](#optional-setups). All five use [OpenTelemetry](https://opentelemetry.io/) and export to the same SigNoz endpoint.\n\nE2B sandboxes have outbound internet access by default, so you need no firewall rule before an exporter reaches SigNoz. If you set `allow_internet_access=False` or a `network` deny rule, read [The sandbox cannot reach SigNoz](#the-sandbox-cannot-reach-signoz) first.\n\n## Monitor E2B Sandboxes from Your Application\n\nThis path shows what each sandbox did: which executions ran, how long each took, how they exited, and where they failed.\n\n### Step 1: Install the packages\n\n```\npip install e2b-code-interpreter opentelemetry-sdk opentelemetry-exporter-otlp-proto-http\n```\n\n`e2b-code-interpreter` wraps the base `e2b` SDK and adds `run_code`. If your agent only runs shell commands, install `e2b` instead, drop the `traced_run_code` helper below, and import `Sandbox` from `e2b` rather than from `e2b_code_interpreter`. The `e2b` package does not contain the `e2b_code_interpreter` module, so the import fails otherwise.\n\n### Step 2: Configure the OpenTelemetry SDK\n\nPoint the standard OpenTelemetry variables at SigNoz. The exporter reads all three, so no endpoint appears in your code.\n\n```\nexport E2B_API_KEY=\"<your-e2b-api-key>\"\nexport OTEL_SERVICE_NAME=\"<your-service-name>\"\nexport OTEL_EXPORTER_OTLP_ENDPOINT=\"https://ingest.<region>.signoz.cloud:443\"\nexport OTEL_EXPORTER_OTLP_HEADERS=\"signoz-ingestion-key=<your-ingestion-key>\"\n```\n\n**Verify these values:**\n\n- `<your-e2b-api-key>` : Created under[E2B Dashboard, Keys](https://e2b.dev/dashboard?tab=keys) .\n- `<your-service-name>` : What your application appears under in SigNoz, and what you filter on in[Validate](#validate) .\n- `<region>` : Your[SigNoz Cloud region](https://signoz.io/docs/ingestion/signoz-cloud/overview/#endpoint) .\n- `<your-ingestion-key>` : Your SigNoz[ingestion key](https://signoz.io/docs/ingestion/signoz-cloud/keys/) .\n\nCreate the providers once, at startup:\n\n```\ntelemetry.py\npython\nimport atexit\n \nfrom opentelemetry import trace\nfrom opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter\nfrom opentelemetry.sdk.resources import Resource\nfrom opentelemetry.sdk.trace import TracerProvider\nfrom opentelemetry.sdk.trace.export import BatchSpanProcessor\n \n# service.name is not set here. Resource.create() runs the environment\n# detector, which reads the OTEL_SERVICE_NAME exported above.\ntracer_provider = TracerProvider(resource=Resource.create())\ntracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))\ntrace.set_tracer_provider(tracer_provider)\n \n# Orchestrators are often short-lived. An unflushed batch dies with the process.\natexit.register(tracer_provider.shutdown)\n \ntracer = trace.get_tracer(\"e2b-orchestrator\")\n```\n\nThe exporter defaults to OTLP over HTTP on the endpoint above. Do not set `OTEL_EXPORTER_OTLP_PROTOCOL=grpc`, because the HTTP exporter is the only one this package installs.\n\n### Step 3: Add the traced sandbox helpers\n\n`pyqwest` already times each HTTP call the SDK makes. These helpers add what it cannot know: which sandbox the call belongs to, whether the code raised, and how the command exited. They also open a parent span, so the `pyqwest` spans stop rooting their own traces and nest under the session instead.\n\nOpenTelemetry has no semantic conventions for sandboxes, so `e2b.*` is a custom namespace. Keep the names stable across your services so that dashboards and alerts keep working.\n\n```\ntraced_sandbox.py\npython\nfrom contextlib import contextmanager\n \nfrom e2b import CommandExitException\nfrom e2b_code_interpreter import Sandbox\nfrom opentelemetry.trace import Status, StatusCode\n \nfrom telemetry import tracer\n \n \n@contextmanager\ndef traced_sandbox(template=None, timeout=300, **kwargs):\n    \"\"\"Create a sandbox, trace its whole life, and always kill it.\"\"\"\n    with tracer.start_as_current_span(\"e2b sandbox session\") as span:\n        span.set_attribute(\"e2b.sandbox.timeout\", timeout)\n        if template:\n            span.set_attribute(\"e2b.template.id\", template)\n \n        sandbox = Sandbox.create(template=template, timeout=timeout, **kwargs)\n        span.set_attribute(\"e2b.sandbox.id\", sandbox.sandbox_id)\n        try:\n            yield sandbox\n        finally:\n            sandbox.kill()\n \n \ndef traced_run_code(sandbox, code, **kwargs):\n    \"\"\"Run code in a sandbox and record how it finished.\"\"\"\n    with tracer.start_as_current_span(\"e2b run_code\") as span:\n        span.set_attribute(\"e2b.sandbox.id\", sandbox.sandbox_id)\n        span.set_attribute(\"e2b.execution.code_bytes\", len(code.encode()))\n \n        execution = sandbox.run_code(code, **kwargs)\n \n        span.set_attribute(\"e2b.execution.results\", len(execution.results))\n        if execution.error:\n            span.set_attribute(\"e2b.execution.error.name\", execution.error.name)\n            span.set_attribute(\"e2b.execution.error.value\", execution.error.value)\n            span.set_status(Status(StatusCode.ERROR, execution.error.name))\n        return execution\n \n \ndef traced_command(sandbox, cmd, **kwargs):\n    \"\"\"Run a shell command in a sandbox and record its exit code.\"\"\"\n    with tracer.start_as_current_span(\"e2b commands.run\") as span:\n        span.set_attribute(\"e2b.sandbox.id\", sandbox.sandbox_id)\n        span.set_attribute(\"e2b.command\", cmd)\n        try:\n            result = sandbox.commands.run(cmd, **kwargs)\n        except CommandExitException as exc:\n            # commands.run raises on a non-zero exit code. The exception is\n            # also a CommandResult, so it carries the exit code and the output.\n            # start_as_current_span records the exception and sets the error\n            # status on the way out, so only the exit code needs recording.\n            span.set_attribute(\"e2b.command.exit_code\", exc.exit_code)\n            raise\n \n        span.set_attribute(\"e2b.command.exit_code\", result.exit_code)\n        return result\n```\n\nThe two helpers handle failure differently, because the SDK does. `run_code` returns an `Execution` with `error` set when the code raises, so the helper reads `execution.error` and never sees an exception. `commands.run` raises `CommandExitException` on any non-zero exit code instead of returning, so the helper catches it, records the exit code, and re-raises. Code that tests `result.exit_code != 0` after `commands.run` never runs.\n\nThe `finally` block matters. A sandbox that is not killed keeps running until its timeout expires, and E2B bills it for that whole time.\n\nDo not put `execution.logs.stdout` or the command output on a span. Output is unbounded, and a span attribute is the wrong place for it. [Trace the Code Inside a Sandbox](#trace-the-code-inside-a-sandbox) sends it as log records instead.\n\n### Step 4: Run your orchestrator\n\n```\nrun_agent.py\npython\nfrom traced_sandbox import traced_command, traced_run_code, traced_sandbox\n \nwith traced_sandbox(timeout=120) as sandbox:\n    execution = traced_run_code(sandbox, \"print(sum(i * i for i in range(200000)))\")\n    print(execution.logs.stdout)\n \n    result = traced_command(sandbox, \"pip install --quiet pandas\")\n    print(result.exit_code)\n \n    failing = traced_run_code(sandbox, \"1 / 0\")\n    print(failing.error.name)\npython run_agent.py\n```\n\nOne trace appears per sandbox, with the session span as its root and one child span per execution. The last execution divides by zero on purpose, so that the first trace you look at contains an error span.\n\n## Validate\n\nWait a minute after your first run, then check each signal.\n\n**Traces:** Open the [Traces explorer](https://signoz.io/docs/userguide/traces/) and filter on `service.name = '<your-service-name>'`. Look for the `e2b sandbox session` root span.\n\n**Errors:** Filter the same view on `status.code = 'Error'`. The `e2b run_code` span for `1 / 0` carries `e2b.execution.error.name = 'ZeroDivisionError'`.\n\n**One sandbox:** Filter on `e2b.sandbox.id` to isolate every span from a single sandbox.\n\nEach trace also holds client spans named `POST` and `DELETE` that you did not create. Those come from `pyqwest`, and [What the SDK Emits on Its Own](#what-the-sdk-emits-on-its-own) covers them.\n\n## ## What the SDK Emits on Its Own\n\n`e2b` depends on `pyqwest`, and `pyqwest` depends on `opentelemetry-api`. Once your process has a tracer provider and a meter provider, `pyqwest` reports through them. You install no instrumentation package, and you write no code.\n\n### Spans\n\nEvery HTTP call the SDK makes produces a client span under the instrumentation scope `pyqwest`. The span name is the HTTP method alone.\n\n| Attribute | Example | \n|---|---|\n| `http.request.method` | `POST` | \n| `server.address` | `api.e2b.app` | \n| `server.port` | `443` | \n| `url.full` | `https://api.e2b.app/sandboxes/<sandbox-id>` | \n| `http.response.status_code` | `201` | \n| `network.protocol.name` | `http` | \n| `network.protocol.version` | `2` | \n\nSandbox creation appears as `POST https://api.e2b.app/sandboxes`, and `kill()` as `DELETE https://api.e2b.app/sandboxes/<sandbox-id>`. Code execution goes to the sandbox host instead, as `POST https://<port>-<sandbox-id>.e2b.app/execute`, and `commands.run` as `POST https://sandbox.e2b.app/process.Process/Start`.\n\nThe sandbox id appears only inside `url.full`, so grouping these spans by sandbox means parsing that URL. Without an active span of your own, each one roots a separate trace.\n\n### Metrics\n\n`pyqwest` reports seven metrics under the same scope. Two describe the HTTP client, and five describe the Rust runtime underneath it.\n\n| Metric | Type | \n|---|---|\n| `http.client.request.duration` | Histogram | \n| `http.client.active_requests` | Sum | \n| `rust.async_runtime.alive_tasks.count` | Sum | \n| `rust.async_runtime.blocking_threads.count` | Sum | \n| `rust.async_runtime.task_queue.size` | Sum | \n| `rust.async_runtime.worker_busy_duration` | Sum | \n| `rust.async_runtime.workers.count` | Sum | \n\n`http.client.request.duration` gives you E2B API latency for free, split by host and status code. Chart it next to your session spans to separate a slow sandbox from a slow E2B API.\n\nThese names belong to `pyqwest`, not to E2B. Any other library in your process that uses `pyqwest` reports into the same metrics.\n\n## ## Optional Setups\n\nFive optional paths, each independent of the others. Open the one that answers your question, and skip the rest.\n\n## ### Trace the Code Inside a Sandbox\n\nThe spans above measure a sandbox from the outside. They cannot say which line of the generated code was slow. This path instruments the code that runs inside the sandbox and joins it to the same trace.\n\n#### Step 1: Write the workload script\n\nThe script reads the endpoint from the environment, so it holds no credentials of its own. `E2B_SANDBOX_ID` and `E2B_TEMPLATE_ID` are set by E2B in every sandbox, and become resource attributes.\n\n```\nsandbox_app.py\npython\nimport logging\nimport os\n \nfrom opentelemetry import trace\nfrom opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter\nfrom opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter\nfrom opentelemetry.sdk._logs import LoggerProvider, LoggingHandler\nfrom opentelemetry.sdk._logs.export import BatchLogRecordProcessor\nfrom opentelemetry.sdk.resources import Resource\nfrom opentelemetry.sdk.trace import TracerProvider\nfrom opentelemetry.sdk.trace.export import BatchSpanProcessor\nfrom opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator\n \nresource = Resource.create(\n    {\n        \"e2b.sandbox.id\": os.environ.get(\"E2B_SANDBOX_ID\", \"\"),\n        \"e2b.template.id\": os.environ.get(\"E2B_TEMPLATE_ID\", \"\"),\n    }\n)\n \ntracer_provider = TracerProvider(resource=resource)\ntracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))\ntrace.set_tracer_provider(tracer_provider)\n \nlogger_provider = LoggerProvider(resource=resource)\nlogger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))\nlogging.getLogger().addHandler(LoggingHandler(logger_provider=logger_provider))\nlogging.getLogger().setLevel(logging.INFO)\n \n# The parent context arrives as an environment variable. The OpenTelemetry SDK\n# does not read TRACEPARENT on its own, so extract it here.\nparent = TraceContextTextMapPropagator().extract(\n    {\"traceparent\": os.environ.get(\"TRACEPARENT\", \"\")}\n)\n \ntracer = trace.get_tracer(\"sandbox-workload\")\nlog = logging.getLogger(\"sandbox-workload\")\n \nwith tracer.start_as_current_span(\"workload\", context=parent) as span:\n    log.info(\"starting work\")\n    with tracer.start_as_current_span(\"workload.compute\"):\n        total = sum(i * i for i in range(200000))\n    log.info(\"compute finished with total %s\", total)\n    span.set_attribute(\"workload.total\", total)\n \ntracer_provider.force_flush()\nlogger_provider.force_flush()\n```\n\nFlush both providers before the process exits. Sandbox processes are short-lived, and an unflushed batch dies with the process.\n\n#### Step 2: Ship the script and pass the parent context down\n\nInject the current span context into a carrier, then hand it to the sandbox as an environment variable.\n\n```\nrun_traced_workload.py\npython\nimport os\n \nfrom opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator\n \nfrom telemetry import tracer\nfrom traced_sandbox import traced_command, traced_sandbox\n \nwith traced_sandbox(timeout=300) as sandbox:\n    with open(\"sandbox_app.py\", \"rb\") as f:\n        sandbox.files.write(\"/home/user/sandbox_app.py\", f.read())\n \n    traced_command(\n        sandbox,\n        \"pip install --quiet opentelemetry-sdk opentelemetry-exporter-otlp-proto-http\",\n    )\n \n    carrier = {}\n    TraceContextTextMapPropagator().inject(carrier)\n \n    traced_command(\n        sandbox,\n        \"python3 /home/user/sandbox_app.py\",\n        envs={\n            \"TRACEPARENT\": carrier[\"traceparent\"],\n            \"OTEL_SERVICE_NAME\": \"<your-sandbox-service-name>\",\n            # Forward the two variables Step 2 already exported, so the\n            # endpoint and the ingestion key are defined in exactly one place.\n            \"OTEL_EXPORTER_OTLP_ENDPOINT\": os.environ[\"OTEL_EXPORTER_OTLP_ENDPOINT\"],\n            \"OTEL_EXPORTER_OTLP_HEADERS\": os.environ[\"OTEL_EXPORTER_OTLP_HEADERS\"],\n        },\n    )\n```\n\nInject the carrier inside `traced_sandbox`, not before it. A carrier built outside the session span carries the wrong parent, and the sandbox spans then root their own trace.\n\nGive the sandbox its own `OTEL_SERVICE_NAME`. The spans share a trace with your orchestrator but describe a different process, and a separate service name keeps the two apart in the service list.\n\nForwarding `OTEL_EXPORTER_OTLP_HEADERS` puts the ingestion key in the environment of the sandbox. Anything running there can read it. Use a key scoped to sandbox traffic, and rotate it like any other shared secret.\n\nTo skip the install on every run, bake the OpenTelemetry packages into a [custom template](https://docs.e2b.dev/template/quickstart) and create sandboxes from it.\n\n## ### Collect Sandbox Resource Metrics\n\nE2B samples CPU, memory, and disk inside every sandbox every 5 seconds. `get_metrics()` returns that history as a list. Nothing pushes it anywhere, so poll it and record the newest sample as OpenTelemetry gauges.\n\n```\nsandbox_metrics.py\npython\nimport time\n \nfrom opentelemetry import metrics\nfrom opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter\nfrom opentelemetry.sdk.metrics import MeterProvider\nfrom opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader\nfrom opentelemetry.sdk.resources import Resource\n \nreader = PeriodicExportingMetricReader(OTLPMetricExporter())\nmeter_provider = MeterProvider(resource=Resource.create(), metric_readers=[reader])\nmetrics.set_meter_provider(meter_provider)\nmeter = metrics.get_meter(\"e2b-orchestrator\")\n \n# Sandboxes to report on, keyed by sandbox id. Add on create, remove on kill.\ntracked = {}\n \n# Every gauge below runs its own callback, and they all fire within the same\n# collection. Without this cache that is one get_metrics() call per gauge, so\n# six per sandbox per collection. The TTL is long enough to cover one\n# collection and shorter than any sane export interval, so each collection\n# still reads fresh values.\n_CACHE_TTL_SECONDS = 2.0\n_cache = {}\n \n \ndef _latest(sandbox_id, sandbox):\n    now = time.monotonic()\n    cached = _cache.get(sandbox_id)\n    if cached is not None and now - cached[0] < _CACHE_TTL_SECONDS:\n        return cached[1]\n \n    samples = sandbox.get_metrics()\n    sample = samples[-1] if samples else None\n    _cache[sandbox_id] = (now, sample)\n    return sample\n \n \ndef _observe(field, scale=1):\n    def callback(options):\n        for sandbox_id, sandbox in list(tracked.items()):\n            sample = _latest(sandbox_id, sandbox)\n            if sample is None:\n                continue\n            yield metrics.Observation(\n                getattr(sample, field) * scale,\n                {\"e2b.sandbox.id\": sandbox_id},\n            )\n \n    return callback\n \n \nmeter.create_observable_gauge(\n    \"e2b.sandbox.cpu.used_pct\", callbacks=[_observe(\"cpu_used_pct\")], unit=\"%\"\n)\nmeter.create_observable_gauge(\n    \"e2b.sandbox.cpu.count\", callbacks=[_observe(\"cpu_count\")], unit=\"{cpu}\"\n)\nmeter.create_observable_gauge(\n    \"e2b.sandbox.memory.used\", callbacks=[_observe(\"mem_used\")], unit=\"By\"\n)\nmeter.create_observable_gauge(\n    \"e2b.sandbox.memory.total\", callbacks=[_observe(\"mem_total\")], unit=\"By\"\n)\nmeter.create_observable_gauge(\n    \"e2b.sandbox.disk.used\", callbacks=[_observe(\"disk_used\")], unit=\"By\"\n)\nmeter.create_observable_gauge(\n    \"e2b.sandbox.disk.total\", callbacks=[_observe(\"disk_total\")], unit=\"By\"\n)\n```\n\nRegister a sandbox in `tracked` after `Sandbox.create`, and before `kill` remove it from both `tracked` and `_cache`, next to the two calls in `traced_sandbox`. A sandbox left in `_cache` holds its last sample until the process exits.\n\nThe default export interval is 60 seconds, and each collection makes one API call per tracked sandbox, because the six gauges share the cached snapshot. Lower `OTEL_METRIC_EXPORT_INTERVAL` for finer resolution, and watch the request volume when you track many sandboxes at once.\n\nThe gauge records only the newest sample. The OpenTelemetry metrics API stamps each value with its collection time, so the older samples in the list cannot keep their own timestamps. Read them with `Sandbox.get_metrics(sandbox_id, start=..., end=...)` when you need the full history after a run.\n\n`get_metrics()` returns an empty list for the first second or two after a sandbox starts, before E2B collects the first sample. The callback above skips that case instead of reporting a zero.\n\n## ### Record Sandbox Lifecycle Events\n\nThe events API reports when sandboxes are created, updated, snapshotted, paused, resumed, and killed across your whole project, including the sandboxes your own code did not create. E2B keeps these events for 7 days.\n\n```\nlifecycle_events.py\npython\nimport os\nimport time\n \nimport requests\nfrom opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter\nfrom opentelemetry.sdk._logs import LoggerProvider, LoggingHandler\nfrom opentelemetry.sdk._logs.export import BatchLogRecordProcessor\nfrom opentelemetry.sdk.resources import Resource\nimport logging\n \nlogger_provider = LoggerProvider(resource=Resource.create())\nlogger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))\nlog = logging.getLogger(\"e2b-lifecycle\")\nlog.addHandler(LoggingHandler(logger_provider=logger_provider))\nlog.setLevel(logging.INFO)\n \nseen = set()\n \n \ndef poll_once():\n    response = requests.get(\n        \"https://api.e2b.app/events/sandboxes\",\n        params={\"limit\": 100},\n        headers={\"X-API-Key\": os.environ[\"E2B_API_KEY\"]},\n        timeout=30,\n    )\n    response.raise_for_status()\n \n    for event in response.json():\n        if event[\"id\"] in seen:\n            continue\n        seen.add(event[\"id\"])\n        log.info(\n            event[\"type\"],\n            extra={\n                \"e2b.sandbox.id\": event[\"sandboxId\"],\n                \"e2b.template.id\": event[\"sandboxTemplateId\"],\n                \"e2b.event.type\": event[\"type\"],\n                \"e2b.event.timestamp\": event[\"timestamp\"],\n            },\n        )\n \n \nwhile True:\n    poll_once()\n    logger_provider.force_flush()\n    time.sleep(30)\n```\n\nThe API returns the newest events first and repeats them on every call, so track the event `id` and skip the ones you already sent. The `seen` set above is in memory. Persist it if the poller restarts often, or you record the same event twice.\n\nFilter the response with `types=sandbox.lifecycle.created&types=sandbox.lifecycle.killed` when you only want a subset. A single call returns at most 100 events, so a project that churns thousands of sandboxes needs a shorter interval or the `offset` parameter.\n\nSandbox lifecycle webhooks push the same events instead. Use them when you already run an HTTP endpoint, because they remove the polling loop and the deduplication. See [Sandbox lifecycle webhooks](https://docs.e2b.dev/sandbox/lifecycle-events-webhooks).\n\n## ### Run the Sandbox as an AI Agent Tool\n\nWhen a model calls the sandbox as a tool, add the GenAI attributes to the session span so that it joins the rest of your LLM traces:\n\n```\nspan.set_attributes(\n    {\n        \"gen_ai.operation.name\": \"execute_tool\",\n        \"gen_ai.tool.name\": \"e2b_sandbox\",\n        \"gen_ai.tool.type\": \"extension\",\n        \"gen_ai.tool.call.id\": tool_call.id,\n    }\n)\n```\n\nName the span `execute_tool e2b_sandbox` to follow the convention. These attributes are at development stability in the [OpenTelemetry GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai), so expect them to change.\n\nE2B ships templates for several coding agents, including Claude Code and Codex. When you run one of those inside a sandbox, the agent exports its own telemetry. Pass its variables through `envs` at creation and read [Monitor Claude Code](https://signoz.io/docs/claude-code-monitoring/) or [Monitor Codex](https://signoz.io/docs/codex-monitoring/) for the rest.\n\n## ### Export E2B Telemetry Directly (Enterprise)\n\nE2B can push its own metrics and logs to any OTLP HTTP endpoint. This path needs no code, and it reports the sandboxes your application never sees. It is available on the Enterprise plan only.\n\nGive E2B these values during onboarding:\n\n- OTLP HTTP endpoint: `https://ingest.<region>.signoz.cloud`\n- Header: `signoz-ingestion-key` set to your[ingestion key](https://signoz.io/docs/ingestion/signoz-cloud/keys/)\n\nE2B sends OTLP over HTTP and protobuf, and appends `/v1/metrics` and `/v1/logs` itself. Metrics arrive under the `e2b.*` namespace and mirror the sandbox resource usage shown in the E2B dashboard. Logs arrive with `service_name: e2b` and carry sandbox lifecycle events.\n\nDelivery is best effort. E2B retries when it can, and drops metrics or logs when the endpoint stays unreachable.\n\nTo request it, contact [enterprise@e2b.dev](mailto:enterprise@e2b.dev). For BYOC deployments, E2B can configure the export from your own environment.\n\nDo not run this path and [Collect Sandbox Resource Metrics](#collect-sandbox-resource-metrics) at the same time without checking the names. Both report sandbox CPU, memory, and disk under the `e2b.` prefix, from different collection points, and a chart that mixes them double counts.\n\n## ## Attribute Reference\n\nOpenTelemetry has no semantic conventions for sandboxes. Everything below is a custom namespace that this page defines.\n\n### Session span\n\nName: `e2b sandbox session`. Kind: internal. Covers create through kill.\n\n| Attribute | Type | Description | \n|---|---|---|\n| `e2b.sandbox.id` | string | Sandbox id returned by `Sandbox.create` | \n| `e2b.template.id` | string | Template the sandbox was created from | \n| `e2b.sandbox.timeout` | int | Timeout in seconds passed at creation | \n\n### Execution spans\n\nNames: `e2b run_code` and `e2b commands.run`. Kind: internal. One per call.\n\n| Attribute | Type | Description | \n|---|---|---|\n| `e2b.sandbox.id` | string | Sandbox the execution ran in | \n| `e2b.execution.code_bytes` | int | Size of the code sent to `run_code` | \n| `e2b.execution.results` | int | Number of results returned | \n| `e2b.execution.error.name` | string | Exception class raised inside the sandbox | \n| `e2b.execution.error.value` | string | Exception message | \n| `e2b.command` | string | Shell command passed to `commands.run` | \n| `e2b.command.exit_code` | int | Exit code of the command, read from `CommandExitException` when it is not zero | \n\n### Environment variables inside a sandbox\n\nE2B sets these in every sandbox, and the workload script reads them as resource attributes.\n\n| Variable | Description | \n|---|---|\n| `E2B_SANDBOX` | Set to `true` in every sandbox process | \n| `E2B_SANDBOX_ID` | Id of the current sandbox | \n| `E2B_TEMPLATE_ID` | Template the sandbox was created from | \n\nThe E2B CLI does not see these as environment variables. They are files under `/run/e2b/`.\n\n## ## Troubleshooting\n\n### No spans reach SigNoz, and the script prints no error\n\nSymptom: the sandbox runs, the code returns output, and no service appears in SigNoz.\n\nLikely cause: the process exited before the batch processor flushed.\n\nFix: call `tracer_provider.shutdown()` at exit, as `telemetry.py` does with `atexit`.\n\nVerify: `service.name = '<your-service-name>'` returns spans in the Traces explorer.\n\n### OTLPExporterError: Unauthorized\n\nSymptom: the exporter logs `Unauthorized` and retries.\n\nLikely cause: the ingestion key is wrong, or the header name is not exactly `signoz-ingestion-key`.\n\nFix: re-copy the key from SigNoz Ingestion Settings. Make sure that `OTEL_EXPORTER_OTLP_HEADERS` holds no quotes and no spaces around the `=`.\n\nVerify: `curl -s -o /dev/null -w '%{http_code}' -H \"signoz-ingestion-key: <your-ingestion-key>\" https://ingest.<region>.signoz.cloud/v1/traces` returns `405`, not `401`.\n\n### Spans arrive under service.name unknown_service\n\nSymptom: traces appear, under a service named `unknown_service` or `unknown_service:python`.\n\nLikely cause: `OTEL_SERVICE_NAME` was not set in the environment that started the process.\n\nFix: export it before you run, as in Step 2, or pass `Resource.create({\"service.name\": \"<your-service-name>\"})`.\n\nVerify: the service list shows your name instead.\n\n### The sandbox cannot reach SigNoz\n\nSymptom: your orchestrator spans arrive, and nothing from inside the sandbox does.\n\nLikely cause: the sandbox was created with `allow_internet_access=False`, or with a `network` rule that denies outbound traffic.\n\nFix: create the sandbox with internet access, which is the default, or add the ingestion host to the network allow rules. Existing sandboxes keep the rules they were created with, so create a new one after the change.\n\nVerify: from inside the sandbox, `curl -s -o /dev/null -w '%{http_code}' https://ingest.<region>.signoz.cloud` returns an HTTP status. `000` means the connection is still blocked.\n\n### Sandbox spans root their own trace\n\nSymptom: the in-sandbox spans arrive, and they do not appear under the session span.\n\nLikely cause: you injected the carrier outside the session span, or `TRACEPARENT` never reached the command.\n\nFix: inject the carrier inside the `with traced_sandbox(...)` block, and pass it in the `envs` of the command that runs the script.\n\nVerify: `echo $TRACEPARENT` inside the sandbox prints a `00-` prefixed value.\n\n### get_metrics returns an empty list\n\nSymptom: the metric callback yields nothing for a sandbox that is running.\n\nLikely cause: the first sample is not collected yet.\n\nFix: wait. E2B collects a sample every 5 seconds, and the first one takes a second or more after the sandbox starts.\n\nVerify: `e2b sandbox metrics <sandbox-id>` on the command line prints rows.\n\n## ## Limitations\n\n- **The spans the SDK emits describe HTTP calls, not sandboxes.**`pyqwest` names each span after the HTTP method and records no sandbox id, template id, or exit code. The sandbox id appears only inside`url.full` .\n- **Do not install `opentelemetry-instrumentation-httpx`.** The SDK sends its calls through` pyqwest` and`connectrpc` rather than`httpx` , so that package adds nothing.`pyqwest` already covers the transport.\n- **`pyqwest` spans root their own traces.** With no active span in your process, every SDK call starts a separate trace. The session span in Step 3 is what groups them.\n- **Sandbox output is not exported.**`execution.logs.stdout` and the output of`commands.run` stay in your process unless you send them yourself. Route them through the OpenTelemetry logs SDK, as[Trace the Code Inside a Sandbox](#trace-the-code-inside-a-sandbox) does.\n- **Resource metrics lose their original timestamps.**`get_metrics()` returns a 5 second history, and the OpenTelemetry metrics API stamps each value with the collection time. Only the newest sample is faithful.\n- **`commands.run` raises instead of returning a failed result.** A non-zero exit code produces`CommandExitException` , which subclasses both`SandboxException` and`CommandResult` . Catch it to read`exit_code` ,`stdout` , and`stderr` .\n- **Lifecycle events expire after 7 days.** Poll inside that window. Events older than the retention period are gone from the API.\n- **Direct export needs an Enterprise plan.** Everything else on this page works on the free Hobby plan, which allows 20 concurrent sandboxes and a 1 hour session.\n\n## Next Steps\n\n- [Set a trace-based alert](https://signoz.io/docs/alerts-management/trace-based-alerts/) on`e2b commands.run` spans with a non-zero`e2b.command.exit_code` , so a failing agent command pages you.\n- [Build a dashboard](https://signoz.io/docs/userguide/manage-dashboards/) over`e2b.sandbox.memory.used` against`e2b.sandbox.memory.total` to find sandboxes that run out of memory.\n- [Add a log-based alert](https://signoz.io/docs/alerts-management/log-based-alerts/) on`sandbox.lifecycle.killed` events to catch sandboxes that die before their work finishes.\n- [Explore the traces](https://signoz.io/docs/userguide/traces/) to find which execution is slowest across sandboxes.\n- Instrument the model calls that generate the code, so agent steps and LLM spans land in one trace.\n\n## Related integrations\n\nE2B runs these agents and frameworks inside its sandboxes. Instrument them so the work inside a sandbox and the model calls that drove it land in one trace:\n\n- [Monitor Claude Code with OpenTelemetry](https://signoz.io/docs/claude-code-monitoring/) - track session token usage, cost per task, and API performance\n- [Monitor OpenAI Codex with OpenTelemetry](https://signoz.io/docs/codex-monitoring/) - track Codex CLI sessions, token spend, and command-level traces\n- [Monitor Agno agents with OpenTelemetry](https://signoz.io/docs/agno-monitoring/) - trace agent runs, tool calls, and model requests\n- [CrewAI observability with OpenTelemetry](https://signoz.io/docs/crewai-observability/) - trace crews, tasks, and the tools each agent calls\n- [Mastra observability with OpenTelemetry](https://signoz.io/docs/mastra-observability/) - trace workflows, steps, and agent tool use\n\nBrowse [all LLM observability integrations](https://signoz.io/docs/llm-observability/) to instrument the rest of your stack.\n\n## Get Help\n\nIf you need help with the steps in this topic, please reach out to us on [SigNoz Community Slack](https://signoz.io/slack/). If you are a SigNoz Cloud user, please use in product chat support located at the bottom right corner of your SigNoz instance or contact us at [cloud-support@signoz.io](mailto:cloud-support@signoz.io).", "url": "https://wpnews.pro/news/e2b-sandbox-monitoring-tracing-with-opentelemetry", "canonical_source": "https://signoz.io/docs/e2b-monitoring", "published_at": "2026-09-15 00:00:00+00:00", "updated_at": "2026-09-16 06:36:59.981887+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "developer-tools", "mlops"], "entities": ["E2B", "SigNoz", "OpenTelemetry", "e2b-code-interpreter", "Python 3.10"], "alternates": {"html": "https://wpnews.pro/news/e2b-sandbox-monitoring-tracing-with-opentelemetry", "markdown": "https://wpnews.pro/news/e2b-sandbox-monitoring-tracing-with-opentelemetry.md", "text": "https://wpnews.pro/news/e2b-sandbox-monitoring-tracing-with-opentelemetry.txt", "jsonld": "https://wpnews.pro/news/e2b-sandbox-monitoring-tracing-with-opentelemetry.jsonld"}}