OTEL Pipeline
This page documents the OpenTelemetry logs pipeline shared by playroll-cpp and playroll-ui: how each service ships logs, what attributes they attach, and which fields are safe to filter or group by in SigNoz queries.
Both services target the same OTLP/HTTP JSON endpoint and serialize identically-shaped resource attributes so dashboards can group by user, host, or release channel without branching per service. The schemas are intentionally aligned — when the two diverge, playroll-cpp/src/util/logger.cpp::sendLogsToOtel is the source of truth and playroll-ui follows.
Endpoint and transport
| Property | Value |
|---|---|
| Endpoint | https://otel.playroll.gg/v1/logs |
| Protocol | OTLP/HTTP JSON (one POST per batch, Content-Type: application/json) |
| Auth | None — the receiver accepts unauthenticated POSTs from clients. |
playroll-cpp decodes the endpoint at runtime from an XOR-obfuscated literal in include/service/endpoint_config.hpp (config::otelEndpoint()). playroll-ui resolves it from the PLAYROLL_OTEL_ENDPOINT env var and falls back to the same default in src/main/telemetry/otel-logger.ts (normalizeOtelEndpoint). The UI normalizer appends /v1/logs if the path is missing, matching the cpp parseEndpoint behavior.
Resource attribute schema (aligned across services)
Resource attributes appear once per resourceLogs entry and describe the emitter. SigNoz indexes them as resources_string['<key>'] and they are the right place to filter when you want "all logs from this user / host / channel" without paying per-record attribute cost.
| Key | Type | Source — playroll-cpp | Source — playroll-ui | Notes |
|---|---|---|---|---|
service.name | string | setServiceInfo("playroll-cpp", ...) in service_logger_config.cpp | constructor literal "playroll-ui" in src/main/index.ts | The only intentionally divergent value between the two services. Use this to split queries per service. |
service.version | string | projectVersion constant passed to setServiceInfo (CMake PLAYROLL_VERSION) | app.getVersion() (Electron package version, falls back to "0.0.0") | Application version. |
host.name | string | getHostname() (Winsock gethostname) | os.hostname() (Node) | Windows machine hostname. |
process.pid | int | GetCurrentProcessId() | process.pid | OS process id of the emitting process. |
host.ownerId | string | ownerId from setOwnerCredentials (Supabase user id) | userContext.ownerId from setUserContext (Supabase user id) | Always present, even before login — emitted as empty string "" until credentials arrive, so queries against host.ownerId = '' are valid for "pre-login" filtering. |
app.channel | string | releaseTrack from setReleaseMetadata, canonicalized to test | dev | stable | resolveReleaseTrack(PLAYROLL_RELEASE_TRACK), same canonicalization | Release track. Anything outside the three canonical values is coerced to stable. Only emitted if non-empty. |
app.installer_version | string | PLAYROLL_INSTALLER_VERSION env var, falls back to projectVersion | PLAYROLL_INSTALLER_VERSION env var, falls back to app.getVersion() | Installer-stamped version. Lets dashboards distinguish "installed via 3.1.2 installer" from "running 3.1.2 binary that was hot-swapped". Only emitted if non-empty. |
host.username | string | username from setOwnerCredentials (Steam username, or Supabase user id if Steam username unavailable) | userContext.username from setUserContext (Steam username) | Conditional — only attached once user context arrives. |
host.PlayrollUsername | string | Same as host.username | Same as host.username | Backward-compatible alias used by older analytics dashboards. Always emitted alongside host.username. |
enduser.id | string | ownerId, emitted as a separate attribute alongside host.ownerId | Same | Standard OTEL enduser.id semantic key. Equal to host.ownerId when present; absent when user is not logged in. |
playroll-ui historically also emitted service.instance.id, deployment.environment.name, os.type, and os.release at resource level. These were removed in the cpp-alignment pass (UI commit e60f3cd, 2026-05-15) so the resource shape matches playroll-cpp exactly — do not query against them, they will not appear on new payloads.
How the recurring fields are populated
The fields you will most often filter by come from a small number of code paths. The intent is documented here so dashboards can reason about gaps (e.g. why enduser.id is missing on early-boot events).
host.ownerId and enduser.id
- cpp: set by
util::Logger::setOwnerCredentials(steamUsername, userId). Called fromauth_session_restore.cpp(boot-time token restore fromTokenStore) andauth_http_callbacks.cpp(fresh OAuth completion).userIdis the Supabase auth user id. Until this fires,ownerIdis empty and the resource carrieshost.ownerId = ""with noenduser.idattribute. - ui: set by
OtelLogger.setUserContext({ ownerId, username })frommain/index.tsinside theauth.state_changed → authenticatedbranch.ownerIdisauthManager.getCurrentUser().id(Supabase user id). Cleared byclearUserContext()onlogged_out.
Both services hold initial flushes for up to 120 s waiting for credentials so the first batch ships with enduser.id populated:
- cpp:
startOtelThreadcallsotelCv.wait_for(lock, 120s, [] { return credentialsSet.load(); })before the export loop begins. - ui:
OtelLogger.flushInternalearly-returns while!credentialsSet && Date.now() < credentialsGraceDeadlineMs(default 120 s).shutdown()bypasses the gate so queued events still ship on app exit.
If the user never authenticates during the grace window, events ship without enduser.id — query against host.ownerId = '' to find them.
host.username and host.PlayrollUsername
- cpp:
usernameargument ofsetOwnerCredentials. The caller passes the Steam username when available, otherwise falls back touserIdso the field is never empty post-login. - ui:
usernamefield ofOtelUserContext, sourced fromauthManager.getCurrentUser().steamUsername. May be absent if the user authed without a Steam link.
host.PlayrollUsername is always emitted as a verbatim duplicate of host.username for backward compatibility with dashboards predating the standard key.
app.channel
Both services canonicalize to one of test, dev, stable. Anything else (including missing) is coerced to stable. Treat these as release tracks, not deployment environments — the literal production is intentionally absent from the vocabulary because the OTEL pipeline carries production traffic regardless of the channel value.
- cpp:
service_logger_config.cpp::canonicalReleaseTrackreadsPLAYROLL_RELEASE_TRACKenv var, falls back to the build-timedefaultReleaseTrackpassed by the entry point. - ui:
src/shared/release-track.ts::resolveReleaseTrackreads the same env var, falls back toCOMPILE_TIME_DEFAULT(currently"test").
The installer sets PLAYROLL_RELEASE_TRACK before launching either process, so both binaries see the same channel without rebuilds.
app.installer_version
Set from PLAYROLL_INSTALLER_VERSION env var by both services. When the installer-stamped version diverges from service.version you have a hot-swapped binary (developer build replacing an installed binary, or auto-update mid-flight). When the env var is unset, both services fall back to service.version so the field is never missing post-deployment.
Record attribute schema
Record attributes live on each individual logRecord and describe the event, not the emitter. Use these to filter by event type, severity, or per-event payload — never put user identity here.
| Key | Type | Emitted by | Notes |
|---|---|---|---|
log.level | string | both | Always present. Mirrors severityText. Values: DEBUG, INFO, WARN, ERROR, FATAL. |
log.record_id | string | ui only | UUIDv4 unique per log record. Useful for joining the same record across logs and traces. |
session.id | string | ui only | Per-login session id (regenerated on clearUserContext). Lets you group all events from one user session in SigNoz. |
source.file / source.line | string / int | cpp only | __FILE__ / __LINE__ of the LOG_* call site. UI does not emit source location; use the body and event.name instead. |
thread.id / thread.name | int / string | cpp only | OS thread id and the registered thread label (e.g. RemoteLogger, CaptureV2). UI is single-threaded for telemetry purposes. |
event.name | string | both | Normalized snake-dotted event id (e.g. auth.state_changed, capture.v2.start). On the UI side, OtelLogger.event(level, eventName, ...) normalizes the name and also derives event.domain. |
event.domain | string | ui | First dotted segment of event.name, used as a coarse grouping key in SigNoz. |
payload.<key> | string / int / double / bool | ui (installConsoleBridge) | Auto-extracted primitive fields from objects passed to console.log/info/warn/error/debug. Non-primitive values are skipped to keep the record schema flat. |
log.source | string | ui (installConsoleBridge) | Identifies bridged console calls: console.log, console.warn, etc. |
exception.type / exception.message / exception.stacktrace | string | ui | Auto-extracted when an Error instance is passed to console.*. Only the first Error per call site is captured. |
The full catalog of event.name values from the recording pipeline (cpp) is documented in Telemetry Event Catalog and Core Signals. UI-emitted events are ad-hoc — search otelLogger.event(...) in playroll-ui/src/main/ to enumerate.
Scope attributes
Each batch has a single scope with two fields. They are not commonly filtered on but they identify which logger instance emitted the batch.
| Key | playroll-cpp value | playroll-ui value |
|---|---|---|
scope.name | playroll-logger | playroll-ui-main |
scope.version | 1.0.0 (hardcoded) | mirrors service.version |
Batching, gating, and reliability
| Behavior | playroll-cpp | playroll-ui |
|---|---|---|
| Batch trigger | Buffer reaches 30 records OR the pushInterval timer fires (default 5 s, configurable via setPushInterval). | Buffer reaches maxBatchSize (default 50, env PLAYROLL_OTEL_BATCH_SIZE) OR the flush timer fires (default 10 s, env PLAYROLL_OTEL_FLUSH_MS). |
| Pre-credentials gate | Worker thread waits up to 120 s on a condvar for credentialsSet. | flushInternal no-ops until credentialsSet or grace expires (default 120 s, configurable via credentialsGraceMs). |
| Shutdown drain | stopOtelThread flushes the remaining buffer synchronously. | shutdown() clears the credentials gate and races a final flush() against a 2 s timeout. |
| Retry on HTTP error | Failed batch is re-pushed to the queue head; next tick retries. | Failed batch is unshifted to the queue head; next flush retries. Emits otel.export_reject / otel.export_fail diagnostics throttled to 1 / 10 s. |
| Overflow | Bounded by buffer; cpp does not drop. | Drops oldest records past maxQueueSize (default 2000, env PLAYROLL_OTEL_MAX_QUEUE_SIZE); emits otel.queue_overflow throttled to 1 / 10 s. |
Useful query patterns (SigNoz)
Filter to a specific user across both services:
resources_string['host.ownerId'] = '<supabase-user-id>'
Filter to a specific release channel:
resources_string['app.channel'] = 'stable'
Filter to UI-only or cpp-only events:
resources_string['service.name'] = 'playroll-ui'
resources_string['service.name'] = 'playroll-cpp'
Find pre-login UI events (the credentials grace window expired without a user):
resources_string['service.name'] = 'playroll-ui'
AND resources_string['host.ownerId'] = ''
Find hot-swapped binaries (installer-stamped version differs from running version):
resources_string['app.installer_version'] != resources_string['service.version']
Code references
| Concern | playroll-cpp | playroll-ui |
|---|---|---|
| Endpoint resolution | include/service/endpoint_config.hpp::otelEndpoint | src/main/telemetry/otel-logger.ts::normalizeOtelEndpoint |
| Logger setup | src/service/service_logger_config.cpp::configureServiceLogger | src/main/index.ts (OtelLogger construction) |
| Resource attribute build | src/util/logger.cpp::sendLogsToOtel | src/main/telemetry/otel-logger.ts::buildResourceAttributes |
| User credentials wiring | src/service/auth_session_restore.cpp, src/service/auth_http_callbacks.cpp (call setOwnerCredentials) | src/main/index.ts (calls otelLogger.setUserContext on auth.state_changed) |
| Release track canonicalization | src/service/service_logger_config.cpp::canonicalReleaseTrack | src/shared/release-track.ts::canonicalizeReleaseTrack |
Documentation maintenance
If you change the resource attribute list in either service, update this page in the same change so the cross-service contract stays explicit. Adding new record-level attributes does not require a doc update unless they are meant to be filterable in dashboards.