RootTrace

APM SDKs

RootTrace ships APM SDKs for Python, Node.js, Java, PHP, and Go. They aggregate in process and send JSON payloads to the RootTrace API, so an instrumented service needs no sidecar or separate agent. All five can also profile continuously, switched on per service from the dashboard rather than from your code. After successful initialization, instrumentation failures are contained and do not escape into application code.

LanguageDistributionRuntimeStatus
Pythonroottrace-apm on PyPIPython 3.9+Published
Node.jsroottrace-apm on npmNode.js 18+Published
Javaroottrace-apm-java on GitHubJava 11+Source-only; not yet published to Maven Central
PHProottrace-apm-php on GitHubPHP 8.1+ with ext-curlSource-only; not yet published to Packagist
Goroottrace-apm-go on GitHubGo 1.21+Source-only; no published module tag yet

The detailed reference below covers the published Python and Node.js implementations. Java, PHP, and Go are available for source review and evaluation, but should not be presented as registry-installable dependencies until their first releases are published.

Install

Python, from PyPI:

pip install roottrace-apm

Node.js, from npm:

npm install roottrace-apm

Java, from source. The wrapper has its own repository, and mvn install puts it in your local Maven repository so a project can depend on io.roottrace:roottrace-apm locally. Nothing resolves it from Maven Central yet, so pin the commit you built:

git clone https://github.com/byteaffinity/roottrace-apm-java
cd roottrace-apm-java
mvn install

For PHP and Go, from a RootTrace source release:

# PHP: point Composer at the local apm/php directory as a path repository.
# Go: use a go.mod replace directive that points at the local apm/go directory.

Do not invent a public version for these source-only clients. Pin the RootTrace source release or commit used for evaluation. In offline environments, mirror only packages that have actually been published; see the air-gapped install guide.

Quick start

Python:

import roottrace_apm

apm = roottrace_apm.init(
    service="checkout",
    token="rtc_...",           # or ROOTTRACE_APM_TOKEN
)

requests_total = apm.counter("checkout.requests")
requests_total.add()

with apm.transaction("GET /cart"):
    with apm.span("load-cart", type="db", subtype="postgresql"):
        ...

Node.js:

const apm = require("roottrace-apm").init({
  service: "checkout",
  token: "rtc_...",            // or ROOTTRACE_APM_TOKEN
});

apm.counter("checkout.requests").add();

await apm.transaction("GET /cart", async (tx) => {
  const span = tx.startSpan("load-cart", "db", "postgresql");
  // ...
  span.end();
});

Configuration

Explicit arguments win over environment variables, which win over defaults. init() fails fast (raises/throws) only on a missing service or token, or a non-http(s) API URL. Calling init() a second time returns the existing instance and ignores the new options.

Self-hosted: set the API URL. Unlike the collector, which has no default destination and refuses to start without one, the SDKs default to RootTrace Cloud. An instrumented service that is given a token but no api_url will try to send telemetry to https://api.roottrace.io/api. Pass api_url / apiUrl to init(...), or export ROOTTRACE_API_URL into the process, on every self-hosted deployment.

Option (Python / Node)Env varDefaultMeaning
serviceROOTTRACE_APM_SERVICErequiredService name
tokenROOTTRACE_APM_TOKEN, then ROOTTRACE_COLLECTOR_TOKENrequiredCollector token (rtc_...)
api_url / apiUrlROOTTRACE_API_URLhttps://api.roottrace.io/apiAPI base URL; must be http(s)
interval_seconds / intervalSecondsROOTTRACE_APM_INTERVAL_SECONDS30Flush cadence, clamped 5 to 3600
tagsnonenoneInstance tags merged into every metric; first 8 keys kept
runtime_metrics / runtimeMetricsnonetrueAutomatic process and runtime gauges
service_version / serviceVersionROOTTRACE_APM_SERVICE_VERSIONunsetDeploy version (max 64 chars); powers deploy markers
http_instrumentation / httpInstrumentationnonetruePatch outbound HTTP clients
db_instrumentation / dbInstrumentationnonetruePatch installed database drivers
deploymentROOTTRACE_APM_DEPLOYMENTk8s auto-detectKubernetes deployment name
namespaceROOTTRACE_APM_NAMESPACEk8s auto-detectKubernetes namespace
commit_sha (Python)ROOTTRACE_APM_COMMIT_SHA, then GITHUB_SHAunsetCommit attached to the payload (max 64 chars)

Inside Kubernetes (detected via KUBERNETES_SERVICE_HOST) the SDKs read the namespace from the service-account mount and derive the deployment name from the pod name. Outside a cluster the kubernetes block is omitted unless set explicitly.

service is a billing unit, so pick it deliberately

Plans cap how many distinct APM services a workspace may have, alongside the host cap, and service is the name that count is taken from. Two consequences worth knowing before you roll the SDK out:

  • Every replica of one deployment must pass the same service. It is the logical service, not the instance. Appending a pod name or a hostname turns one service into as many services as you have replicas and will exhaust the cap.
  • The same service in staging and in production counts once, so environment belongs in the environment (the collector token's environment), not in the name.

A service counts while it is reporting and stops counting seven days after its last flush, so a decommissioned service releases its slot without an admin deleting anything. Ingest for a service that is already reporting is never refused. A workspace at its cap refuses only the first flush from a new service name, with 402 and a body naming the limit:

{
  "message": "Workspace APM service limit reached for this subscription.",
  "limit_type": "services",
  "used": 15,
  "limit": 15
}

The same rule and the same status apply to OpenTelemetry ingest, where the name is taken from the service.name resource attribute. Current usage against both caps is on the workspace settings page.

Metrics

Three instrument kinds, identical semantics in both SDKs:

  • Counter: counter(name, tags).add(n) sends count and sum deltas.
  • Gauge: gauge(name, tags).set(v) sends the last value per flush.
  • Timer: timer(name, tags).record(ms) sends count, sum, min, max, and a log2 latency histogram. Python timers are also context managers and offer a @apm.timed() decorator; Node timers offer time(fn) for sync or async functions.

Guardrails: non-finite or non-numeric values are dropped with a throttled warning, names are truncated at 200 chars, at most 8 tag keys per metric, and at most 492 distinct (name, tags) series per flush. The name errors.count is reserved. @apm.timed() in Python is for sync functions only; wrapping an async def records the (near-zero) coroutine creation time, not the await.

Transactions and spans

A transaction groups a unit of work (usually one request) by (name, type). Per flush the SDK ships aggregate count, duration stats, success/failure split, a latency histogram, and a span-type breakdown per group (max 250 groups), plus trace samples: the 2 slowest transactions ship their full span lists (up to 100 spans each).

  • Python: with apm.transaction(name) (also a decorator, sync and async). Spans via with apm.span(name, type, subtype).
  • Node: apm.transaction(name, opts, fn) runs fn in an async-context scope; apm.startTransaction(name) gives manual control. Spans via tx.startSpan(name, type, subtype) ... span.end().

An exception escaping the transaction body marks it failed, captures the error, and re-raises unchanged. Both SDKs adopt an inbound W3C traceparent and inject traceparent into outbound HTTP calls made inside a transaction, so traces continue across services.

Note: in Node, prefer the callback form apm.transaction(...) for nesting. Nesting two manual startTransaction() calls loses the outer transaction from the async context when the inner one ends.

Error capture

capture_exception(exc, handled=True) / captureException(err, { handled }). Passing handled: false also marks the active transaction failed. Errors are fingerprinted (type + culprit + innermost frames) and grouped per flush (max 50 distinct groups; duplicates increment a count). Stack traces are capped at 50 frames, messages at 1000 chars.

Log correlation

Both SDKs can ship structured logs to the same API with the active transaction's trace_id attached, buffered at 500 entries (drop oldest) and sent on the same flush interval.

  • Python: attach roottrace_apm.RootTraceLogHandler(apm) to any stdlib logger. Attribute keys that look like secrets (password, token, api_key, authorization, cookie, and similar) are replaced with [REDACTED] before the record leaves the process.
  • Node: apm.log(level, message, attrs) or apm.logger(). Levels are debug/info/warn/error.

Warning: redaction is a key-name heuristic on attributes only. Secrets in the message text are never scrubbed client side. In the current Node SDK log attributes are shipped as passed, without redaction or size caps, so do not put secrets or large objects in attrs. Values that cannot be JSON-serialized (for example a BigInt) cause that interval's whole log batch to be dropped in Node.

Framework middleware

  • Python WSGI: roottrace_apm.WsgiMiddleware(app) wraps every request in a transaction named METHOD /normalized/path (numeric, UUID, and long hex path segments collapse to :id), records http.request.duration and http.requests tagged by method and status class, and fills the HTTP context (client IP from the first X-Forwarded-For hop, socket peer as remote_ip).
  • Python ASGI: roottrace_apm.AsgiMiddleware(app) for FastAPI and Starlette; on FastAPI the transaction is renamed to the route template after routing. Also starts the event-loop lag monitor.
  • Node/Express: app.use(apm.middleware()), same naming, metrics, and HTTP context; also works with plain http servers.

5xx responses and escaping exceptions mark the transaction failed.

Automatic instrumentation

Enabled by default; each hook silently skips when the library is not installed.

PythonNode.js
Outbound HTTPhttp.client (covers requests/urllib3), httpx, aiohttphttp/https, global fetch (covers axios, got, node-fetch)
MongoDBpymongo, motormongodb
Redisredis-py (sync and asyncio)ioredis, node-redis v4+
SQLasyncpg, SQLAlchemypg, mysql2
Elasticsearchelasticsearch-py@elastic/transport

Outbound HTTP records http.client.duration and http.client.requests tagged by destination host:port and status class, plus an http span when a transaction is active. Database spans carry the operation and table or collection name only: never SQL text, parameters, or Redis keys.

MongoDB instrumentation in Python requires calling init() before constructing the client. Node driver patching resolves modules relative to the SDK, so pnpm strict layouts and Yarn PnP can silently skip it.

Runtime metrics

Shipped every flush unless runtime_metrics=False / runtimeMetrics: false; they do not count against the metric series cap.

  • Both: process.memory.rss_bytes, process.cpu.percent, process.uptime_seconds.
  • Python: process.threads, process.gc.collections, process.gil.lag_ms, and python.eventloop.lag_ms while an asyncio loop is monitored.
  • Node: nodejs.eventloop.lag_ms, nodejs.gc.collections, nodejs.gc.time_ms, nodejs.handles.active.

Continuous profiling

Tracing stops at the span boundary. When the time is inside your own code you get one span reading handle_checkout 380ms and no explanation, and work belonging to no request (GC, background threads, cron, pool churn) is invisible to tracing entirely. Profiling answers both.

There is nothing to configure in your code. Profiling is turned on per service in the dashboard under Profiling → Settings; every SDK polls GET /api/apm/config and starts nothing on its own. That means turning it off during the incident it is causing is a toggle rather than a redeploy.

Each SDK reports what its runtime can actually measure, and says which:

SDKMeasuresMechanismRateExtra dependency
GoCPU, plus a sampled heapruntime/pprof, posted as pprof unchangedFixed 100Hznone
Node.jsCPUV8 via node:inspectorConfigurablenone
PythonWall clock, all threadssys._current_frames() on a daemon threadConfigurablenone
JavaWall clock, all threadsThread.getAllStackTraces() on a daemon threadConfigurablenone
PHPWall clock, per requestext-excimerConfigurableyes

CPU and wall clock are never merged, relabelled, or charted together. The dynamic languages get wall clock because their runtimes cannot give a per-thread CPU sample cheaply: in CPython that means setitimer(ITIMER_PROF), whose signal only ever reaches the main thread and is therefore useless in a threaded web server; on the JVM the CPU-accurate route is JFR's jdk.ExecutionSample, which needs JDK 14 to consume in-process while the artifact targets 11.

That is not a consolation prize. Wall clock is frequently the better answer to "why is my handler slow", because the answer is usually that it was waiting.

PHP needs one extension, and only PHP

pecl install excimer          # or: apt install php-excimer

PHP has neither threads nor userland timers, so nothing inside a request can interrupt it and sampling has to come from the engine. Without the extension Apm::profiling() stays false and every other feature is untouched. Because there is no background loop, one request is one profile; the server merges them into five-minute buckets, so a busy service produces the same flame graph a long-running process would. The config is cached in the system temp directory for a minute and refreshed after the response is sent, so no request ever waits on the control plane.

Two Go notes

  • The sample rate is fixed at 100Hz. pprof.StartCPUProfile sets it itself and the runtime refuses to change it mid-profile, so a rate configured in the UI does not apply to Go. The SDK logs that once rather than ignoring it silently.
  • The heap profile is close to free. The runtime keeps it continuously whether anyone reads it or not, and it answers what a CPU profile cannot: what is still holding memory. Stored under its own profile type, never merged into a CPU flame graph.

What the SDK guarantees

  • Fails closed. If the config cannot be fetched, profiling does not start. An SDK that defaults to on when the control plane is unreachable is the wrong failure direction for code running inside your process.
  • Clamps the server's values independently. Sample rate 10–200Hz among others, enforced in the SDK as well as on the server, so a compromised or spoofed control plane cannot spin a sampling loop inside your process.
  • Weights samples by the period actually achieved, not the one requested; otherwise a starved sampler understates every duration by exactly the fraction of ticks it missed.
  • Drops profiles rather than retrying them. They are large and statistical; a lost window leaves a slightly thinner flame graph, which beats a retry queue growing inside your process.

Overhead is measured and reported where the SDK can see it: Python and Java include their own sampling, Node reports collection and conversion only (V8's sampling is not observable from JavaScript), and Go and PHP report nothing rather than inventing a number for work that happens inside the runtime.

Profiling guide

Transport and delivery

One POST <api_url>/apm/ingest per interval carries metrics, transaction aggregates, error groups, and trace samples; logs go to POST <api_url>/logs/ingest, and profiles to POST <api_url>/apm/profiles when profiling is enabled. Auth is Authorization: Collector <token>, with a 10 second timeout and TLS verification always on (there is no knob to turn it off).

On failure: network errors and 5xx merge the unsent data back into the live buffers and retry next interval; 4xx responses (other than 429) drop the payload since a resend would fail forever; 429 pauses flushing until the Retry-After deadline. There is no head sampling: every transaction is aggregated, and only trace samples (slowest 2 per flush) are sampled. Data is buffered in memory only; nothing is persisted to disk.

shutdown() stops the background flusher and performs a final synchronous flush. Python registers this via atexit; Node registers a best-effort hook on beforeExit and its flush timer never keeps the process alive.

What leaves the process

  • The collector token travels only in the Authorization header.
  • SQL statements, query parameters, and Redis keys are never sent.
  • Outbound HTTP metrics carry host:port only, never the path.
  • Trace samples of incoming requests include the raw request path and query string; the server masks sensitive-looking query parameters at storage, but they do transit, so keep secrets out of URLs.
  • client_ip comes from X-Forwarded-For and is client-spoofable; remote_ip is the kernel-vouched socket peer.