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.
| Language | Distribution | Runtime | Status |
|---|---|---|---|
| Python | roottrace-apm on PyPI | Python 3.9+ | Published |
| Node.js | roottrace-apm on npm | Node.js 18+ | Published |
| Java | roottrace-apm-java on GitHub | Java 11+ | Source-only; not yet published to Maven Central |
| PHP | roottrace-apm-php on GitHub | PHP 8.1+ with ext-curl | Source-only; not yet published to Packagist |
| Go | roottrace-apm-go on GitHub | Go 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-apmNode.js, from npm:
npm install roottrace-apmJava, 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 installFor 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_urlwill try to send telemetry tohttps://api.roottrace.io/api. Passapi_url/apiUrltoinit(...), or exportROOTTRACE_API_URLinto the process, on every self-hosted deployment.
| Option (Python / Node) | Env var | Default | Meaning |
|---|---|---|---|
service | ROOTTRACE_APM_SERVICE | required | Service name |
token | ROOTTRACE_APM_TOKEN, then ROOTTRACE_COLLECTOR_TOKEN | required | Collector token (rtc_...) |
api_url / apiUrl | ROOTTRACE_API_URL | https://api.roottrace.io/api | API base URL; must be http(s) |
interval_seconds / intervalSeconds | ROOTTRACE_APM_INTERVAL_SECONDS | 30 | Flush cadence, clamped 5 to 3600 |
tags | none | none | Instance tags merged into every metric; first 8 keys kept |
runtime_metrics / runtimeMetrics | none | true | Automatic process and runtime gauges |
service_version / serviceVersion | ROOTTRACE_APM_SERVICE_VERSION | unset | Deploy version (max 64 chars); powers deploy markers |
http_instrumentation / httpInstrumentation | none | true | Patch outbound HTTP clients |
db_instrumentation / dbInstrumentation | none | true | Patch installed database drivers |
deployment | ROOTTRACE_APM_DEPLOYMENT | k8s auto-detect | Kubernetes deployment name |
namespace | ROOTTRACE_APM_NAMESPACE | k8s auto-detect | Kubernetes namespace |
commit_sha (Python) | ROOTTRACE_APM_COMMIT_SHA, then GITHUB_SHA | unset | Commit 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
servicein 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 offertime(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 viawith apm.span(name, type, subtype). - Node:
apm.transaction(name, opts, fn)runsfnin an async-context scope;apm.startTransaction(name)gives manual control. Spans viatx.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 manualstartTransaction()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)orapm.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 aBigInt) 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 namedMETHOD /normalized/path(numeric, UUID, and long hex path segments collapse to:id), recordshttp.request.durationandhttp.requeststagged by method and status class, and fills the HTTP context (client IP from the firstX-Forwarded-Forhop, socket peer asremote_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 plainhttpservers.
5xx responses and escaping exceptions mark the transaction failed.
Automatic instrumentation
Enabled by default; each hook silently skips when the library is not installed.
| Python | Node.js | |
|---|---|---|
| Outbound HTTP | http.client (covers requests/urllib3), httpx, aiohttp | http/https, global fetch (covers axios, got, node-fetch) |
| MongoDB | pymongo, motor | mongodb |
| Redis | redis-py (sync and asyncio) | ioredis, node-redis v4+ |
| SQL | asyncpg, SQLAlchemy | pg, mysql2 |
| Elasticsearch | elasticsearch-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, andpython.eventloop.lag_mswhile 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:
| SDK | Measures | Mechanism | Rate | Extra dependency |
|---|---|---|---|---|
| Go | CPU, plus a sampled heap | runtime/pprof, posted as pprof unchanged | Fixed 100Hz | none |
| Node.js | CPU | V8 via node:inspector | Configurable | none |
| Python | Wall clock, all threads | sys._current_frames() on a daemon thread | Configurable | none |
| Java | Wall clock, all threads | Thread.getAllStackTraces() on a daemon thread | Configurable | none |
| PHP | Wall clock, per request | ext-excimer | Configurable | yes |
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-excimerPHP 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.StartCPUProfilesets 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.
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
Authorizationheader. - SQL statements, query parameters, and Redis keys are never sent.
- Outbound HTTP metrics carry
host:portonly, 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_ipcomes fromX-Forwarded-Forand is client-spoofable;remote_ipis the kernel-vouched socket peer.