OverviewSending logs (OTLP)

Sending logs (OTLP)

Point an OpenTelemetry Collector or SDK log exporter at Mythic with the location's publishable key.

Endpoint

POST https://mythic-analytics.gulp.workers.dev/i/v1/logs

POST /v1/logs on the same host is the same endpoint, for exporters that append the default path to a bare host.

ProtocolOTLP/HTTP. Body is an ExportLogsServiceRequest.
EncodingContent-Type: application/x-protobuf or application/json (OTLP/JSON)
CompressionContent-Encoding: gzip, or none
AuthThe location's publishable key: Authorization: Bearer pk_… or X-Api-Key: pk_…
ResponseExportLogsServiceResponse in the request's encoding. Errors are a google.rpc.Status (code, message).

The endpoint is OTLP/HTTP compatible and tested against the official OpenTelemetry exporters. gRPC is not supported.

The publishable key is the same pk_ key the browser snippet uses. It identifies the location; it is not a secret. A location receives logs only when its plan includes logs.

OpenTelemetry Collector

The otlphttp exporter appends /v1/logs to its endpoint, so give it the /i base:

exporters:
  otlphttp/mythic:
    endpoint: https://mythic-analytics.gulp.workers.dev/i
    headers:
      Authorization: Bearer pk_your_publishable_key
    compression: gzip

service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/mythic]

Environment variables

Any OpenTelemetry SDK that reads the standard variables:

OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://mythic-analytics.gulp.workers.dev/i/v1/logs
OTEL_EXPORTER_OTLP_LOGS_HEADERS=Authorization=Bearer%20pk_your_publishable_key
OTEL_SERVICE_NAME=checkout

Python

import logging
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource

provider = LoggerProvider(resource=Resource.create({"service.name": "checkout"}))
provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter(
    endpoint="https://mythic-analytics.gulp.workers.dev/i/v1/logs",
    headers={"Authorization": "Bearer pk_your_publishable_key"},
)))
set_logger_provider(provider)
logging.getLogger().addHandler(LoggingHandler(logger_provider=provider))

log = logging.getLogger("checkout")
log.error("payment declined", extra={"mythic.distinct_id": user_id, "order.id": order_id})

Node.js

import { LoggerProvider, BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { SeverityNumber } from '@opentelemetry/api-logs';

const provider = new LoggerProvider({
  resource: resourceFromAttributes({ 'service.name': 'checkout' }),
  processors: [
    new BatchLogRecordProcessor(new OTLPLogExporter({
      url: 'https://mythic-analytics.gulp.workers.dev/i/v1/logs',
      headers: { Authorization: 'Bearer pk_your_publishable_key' },
    })),
  ],
});

const logger = provider.getLogger('checkout');
logger.emit({
  severityNumber: SeverityNumber.ERROR,
  severityText: 'ERROR',
  body: 'payment declined',
  attributes: { 'mythic.distinct_id': userId, 'order.id': orderId },
});

Flush the provider (await provider.forceFlush()) before a short-lived process exits, or the last batch is lost.

A log record is linked to a person and a session by two attributes your code sets. Mythic reads them at ingestion; nothing else about the record changes.

FieldAttributes read, first match wins
distinct_idmythic.distinct_id, distinct_id, user.id, enduser.id
session_idmythic.session_id, $session_id, session.id

Set them on the log record (or on the resource). The original attributes are kept as sent. service is the resource's service.name, falling back to a record attribute service.name, then unknown.

Linked records:

  • appear on the person: GET /client/v1/data/people/{personId}/logs (Data API, People & Identity) returns the records whose distinct_id is any of the person's identities, read through the identity graph at query time,
  • appear on the session: GET /client/v1/data/replays/{sessionId}/logs (Session Replays), and merged into the session timeline with ?include=logs,
  • reach Replay Vision scanners as warning and error lines on the recording's timeline,
  • are deleted with the person on an erasure request.

Send the browser's ids with each request

Your server only knows the visitor's Mythic ids if the browser sends them. Read them from the SDK and add them as request headers, then copy them onto every log record written while handling that request.

// Browser: next to your other API headers
function mythicIds() {
  const m = window.mythic;
  try {
    const distinctId = m && m.getDistinctId && m.getDistinctId();
    const sessionId = m && m.getSessionId && m.getSessionId();
    return {
      // Before the SDK has loaded, the stub returns itself, not a string: skip it.
      ...(typeof distinctId === 'string' && distinctId ? { 'X-Mythic-Distinct-Id': distinctId } : {}),
      ...(typeof sessionId === 'string' && sessionId ? { 'X-Mythic-Session-Id': sessionId } : {}),
    };
  } catch {
    return {};
  }
}

fetch('/api/checkout', { method: 'POST', headers: { ...mythicIds(), 'Content-Type': 'application/json' }, body });
// Server (Node): keep the ids for the request, attach them to every record
import { AsyncLocalStorage } from 'node:async_hooks';
const request = new AsyncLocalStorage();

app.use((req, res, next) => request.run({
  distinctId: req.get('X-Mythic-Distinct-Id'),
  sessionId: req.get('X-Mythic-Session-Id'),
}, next));

function log(severityNumber, body, attributes = {}) {
  const ids = request.getStore() || {};
  logger.emit({
    severityNumber, body,
    attributes: {
      ...attributes,
      ...(ids.distinctId ? { 'mythic.distinct_id': ids.distinctId } : {}),
      ...(ids.sessionId ? { 'mythic.session_id': ids.sessionId } : {}),
    },
  });
}

Never block a request waiting for the SDK: send no header when the ids are not there yet. A cross-origin API must allow both headers in its CORS Access-Control-Allow-Headers.

Rules

  • Use the identity the SDK uses. The person's logs are matched against the identity values Mythic holds for them: the SDK's distinct ids, and the user_id or email you pass to identify(). A server-side id that never reached Mythic (an internal database key, a JWT subject) matches nobody. If the browser header is missing on an authenticated request, send the same value you pass to identify().
  • Only the person who made the request. Set mythic.distinct_id to the visitor whose request this is. A record about someone else, such as a lead or contact your system processes, must not carry their id as the requester's: it would show up on the wrong person.
  • The ids are hints, not proof. Anyone can send any header, so a record's link says what the client claimed, not who it was. Do not use it for authorization, and do not log secrets or other people's data on the strength of it.
  • Session-only records do not appear on the person. A record with mythic.session_id but no mythic.distinct_id shows on the session and its timeline, not on the person, in this version. Send both.
  • Up to 50 identities per person. A person with more identity values than that is read through the first 50 (distinct_id, user_id and email first). The response says how many were left out (meta.identities_omitted). Device ids are never used, and a value containing a comma, or longer than 256 bytes, cannot be matched.
  • Logs never change identity. A record does not create, merge or split people. It is read through the identity graph as it stands when you query.

Server and browser clocks differ. On a session timeline, logs keep their own absolute time; see GET /client/v1/data/replays/{sessionId}/timeline in the Data API.

Levels

The level comes from severityNumber: 1–4 trace, 5–8 debug, 9–12 info, 13–16 warn, 17–20 error, 21–24 fatal. When it is unset, from severityText: warning is warn, err is error, critical, emerg and alert are fatal, and anything unrecognized is info.

Limits

LimitValue
Request body on the wire1 MB (413)
Request body after gzip4 MB (413)
Records per request1,000 (400)
Records per location20,000 per 10 seconds (429), a burst guard
Record body32 KB; longer bodies are cut and get mythic.body_truncated = true
Attribute key / value256 bytes / 4 KB
Attributes per record or resource128; extra keys are dropped and counted in mythic.attributes_dropped. Person, session and service.name keys are always kept, wherever they appear
Service, scope, event name, severity text256 bytes each
Whole record64 KB; a larger record is rejected
Record age30 days; older records are rejected
Clock skewA timestamp more than 60 seconds in the future is replaced by the receive time

Sizes are UTF-8 bytes, and cuts never split a character. A missing timestamp falls back to the observed time, then to the receive time.

Responses

StatusWhenRetry
200Accepted. partialSuccess.rejectedLogRecords counts rejected records and errorMessage lists reason codes (too_old, record_too_large), never record content.No
400Malformed body, or more than 1,000 recordsNo
401 / 403Missing or unknown key, inactive location, plan without logs (or no plan), HIPAA locationNo
413 / 415Body too large; unsupported content type or encodingNo
429Too many recordsYes, after Retry-After
503Temporarily unable to accept logsYes, after Retry-After

Delivery and retention

Delivery is at least once: a record is stored once it is accepted, and a client retry after a 503, or a redelivery inside Mythic, can store it twice. Counts are of received records. Records are kept 30 days.

Logs are not accepted for locations with HIPAA mode enabled (403).