OpenTelemetry standardizes traces, metrics, and logs. GraphJSON does not expose an OTLP endpoint and is not a distributed tracing backend. Use a controlled adapter to turn selected telemetry outcomes into compact analytical events while your observability backend retains full diagnostic detail.
This pattern is useful when you want to connect service behavior with account, plan, release, workflow, or product outcomes.
Choose the boundary#
Recommended architecture:
application services
↓ OTLP
OpenTelemetry Collector
├─ full signals → observability backend
└─ selected records → adapter → GraphJSON
The OpenTelemetry Collector receives, processes, and exports telemetry. Keep its primary diagnostic pipeline independent.
The GraphJSON branch should contain only decision-relevant events such as:
- terminal API request outcomes
- completed background jobs
- dependency-call summaries
- release-associated failures
- SLO opportunity outcomes
- customer-visible workflow completions
Do not forward every span, metric point, or unstructured log line.
Use an adapter#
GraphJSON's public ingestion contract is:
POST https://api.graphjson.com/api/log
POST https://api.graphjson.com/api/bulk-log
OTLP payloads do not match that contract. Run a small service, stream processor, or queue consumer that:
- accepts the selected OpenTelemetry records
- applies an allowlist
- maps fields into a stable product event
- removes sensitive and high-cardinality data
- batches up to 50 events
- calls GraphJSON with the server-side workspace key
- retries and quarantines failures
Do not put the GraphJSON key in application telemetry attributes or Collector resource metadata. Keep it in the adapter's secret configuration.
Map the OpenTelemetry log model#
The stable OpenTelemetry logs data model separates the occurrence timestamp, observed timestamp, trace context, resource, instrumentation scope, body, attributes, severity, and event name.
Recommended mapping:
| OpenTelemetry | GraphJSON event |
|---|---|
Timestamp |
request timestamp, after conversion to Unix seconds |
ObservedTimestamp |
observed_timestamp only when delay analysis needs it |
EventName |
bounded event |
TraceId |
trace_id |
SpanId |
span_id |
resource service.name |
service |
resource service.version |
release |
resource deployment.environment.name |
environment |
SeverityText |
bounded severity |
selected Attributes |
allowlisted product fields |
Body |
excluded by default |
Use Timestamp when present and fall back to ObservedTimestamp, matching the
OpenTelemetry model's single-timestamp conversion guidance. GraphJSON expects
whole Unix seconds.
Define a compact event#
{
"event": "service_request_completed",
"event_id": "evt_01J...",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"service": "report-api",
"release": "2026.07.26.3",
"environment": "production",
"account_id": "acct_42",
"operation": "report.generate",
"outcome": "succeeded",
"status_family": "2xx",
"duration_ms": 184,
"schema_version": 1
}
Use one event after the operation reaches a terminal outcome. Preserve the trace ID for a controlled pivot into the tracing backend. Do not copy the trace waterfall into GraphJSON.
Convert timestamps safely#
OTLP timestamps are commonly represented as nanoseconds since the Unix epoch. Avoid floating-point conversion before division:
function unixSeconds(record) {
const raw = record.timeUnixNano || record.observedTimeUnixNano;
if (!raw) throw new Error("OpenTelemetry record has no timestamp");
return Number(BigInt(raw) / 1_000_000_000n);
}
Reject or quarantine timestamps outside an expected range. Do not silently replace malformed occurrence time with the adapter's send time.
Normalize attributes#
OpenTelemetry attributes can be high cardinality and may contain sensitive values. Use an explicit map:
function toGraphJSONEvent(record) {
const a = record.attributes || {};
const r = record.resourceAttributes || {};
return {
event: "service_request_completed",
event_id: String(a["event.id"]),
trace_id: record.traceId || null,
span_id: record.spanId || null,
service: String(r["service.name"] || "unknown"),
release: String(r["service.version"] || "unknown"),
environment: String(
r["deployment.environment.name"] || "unknown"
),
account_id: String(a["app.account.id"] || ""),
operation: String(a["app.operation"] || ""),
outcome: String(a["app.outcome"] || ""),
duration_ms: Number(a["app.duration_ms"]),
schema_version: 1
};
}
The exact attribute keys belong to your instrumentation contract. Validate required values, length, enum membership, and type before delivery.
Exclude by default:
- HTTP authorization and cookies
- raw URL query strings
- request and response bodies
- database statements with values
- exception stack traces and messages
- end-user email or name
- prompt or model output
- arbitrary baggage
Filter before the adapter#
Collector processors can filter, transform, redact, and enrich records. Review the current processor catalog for the distribution and stability level you operate.
Use Collector processing to reduce volume and obvious sensitivity, but repeat the allowlist in the adapter. Defense in depth protects against a Collector configuration change sending a broader payload than intended.
Example selection policy:
keep only:
event.name = service.request.completed
resource deployment environment = production
service in approved service allowlist
drop:
health checks
internal polling
raw exception logs
records without an event ID
Batch and deliver#
The adapter can send up to 50 events per GraphJSON bulk request:
const events = records.map((record) => ({
timestamp: unixSeconds(record),
body: toGraphJSONEvent(record)
}));
const response = await fetch(
"https://api.graphjson.com/api/bulk-log",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: process.env.GRAPHJSON_API_KEY,
collection: "service_outcomes",
timestamps: events.map((item) => item.timestamp),
jsons: events.map((item) => JSON.stringify(item.body))
}),
signal: AbortSignal.timeout(10_000)
}
);
if (!response.ok) {
throw new Error(`GraphJSON returned ${response.status}`);
}
Use bounded exponential backoff and jitter for transient failures. Quarantine invalid records rather than retrying them forever. Follow Message-broker and stream-processing ingestion when the bridge consumes a durable stream.
Correlate without joining every span#
GraphJSON can answer:
- which accounts experienced a failed release
- whether a dependency problem reduced product completion
- which operation contributes most customer-visible latency
- whether reliability differs by plan or region
SELECT
JSONExtractString(json, 'release') AS release,
JSONExtractString(json, 'operation') AS operation,
count() AS requests,
countIf(
JSONExtractString(json, 'outcome') != 'succeeded'
) AS failed,
uniqExactIf(
JSONExtractString(json, 'account_id'),
JSONExtractString(json, 'outcome') != 'succeeded'
) AS affected_accounts,
quantileExact(0.95)(
JSONExtractInt(json, 'duration_ms')
) AS p95_ms
FROM service_outcomes
WHERE timestamp >= now() - INTERVAL 7 DAY
GROUP BY release, operation
ORDER BY failed DESC
When an aggregate identifies a problem, use an allowlisted trace_id to open
the trace in the observability system. Restrict that pivot to authorized
operators.
Handle metrics carefully#
OpenTelemetry metrics are pre-aggregated time series with temporality and reset semantics. Do not convert cumulative counter observations into independent product events and sum them again.
Prefer:
- terminal application facts for customer workflows
- a versioned, externally calculated SLI window
- delta values whose temporality is explicit
Keep metric name, unit, temporality, window, and calculation version when publishing an aggregate. Use SLOs, error budgets, and alerts for the GraphJSON analysis pattern.
Operate both pipelines#
Monitor:
- Collector accepted, dropped, and exported records
- adapter input, accepted, rejected, retried, and quarantined records
- oldest queued record age
- mapping and schema version
- GraphJSON request status
- source-to-GraphJSON delay
- control totals by service and event
GraphJSON cannot be the only monitor for the bridge that sends data to GraphJSON.
Integration checklist#
- The primary observability pipeline remains independent.
- GraphJSON receives selected terminal outcomes, not full telemetry.
- An adapter converts OTLP-shaped records to GraphJSON's API contract.
- The workspace key exists only in adapter secret configuration.
- Occurrence timestamps are converted from nanoseconds safely.
- Event IDs support deduplication.
- Attributes use an allowlist and bounded taxonomy.
- Bodies, stacks, secrets, statements, and arbitrary baggage are excluded.
- Collector and adapter both enforce filtering.
- Metrics preserve unit, temporality, and window semantics.
- Trace IDs are protected pivots, not dashboard dimensions.
- Source, adapter, and GraphJSON counts reconcile.