OTLP export

OTLP is the push counterpart to the scrape endpoint: instead of waiting for Prometheus to come and read /metrics, the server sends its metrics to a collector on a timer, in the wire format every major observability backend accepts. Grafana Cloud, Honeycomb, Datadog, New Relic and an otel-collector you run yourself all take the same payload.

It ships as a second mod, pulseotlp_x.x.x.zip, and both zips go in Mods/. The base mod stays a single dll with no dependencies; the OTLP one carries the OpenTelemetry SDK and its Microsoft.Extensions.* fan-out, eighteen dlls in all. That split is not tidiness. The game's mod loader puts every root-level dll of every mod into one shared assembly context with no version arbitration, so a bundled dependency is a collision risk against every other mod on the server, and a server that does not push metrics should not be paying it.

The two mods share no code. Pulse.Otlp.dll has no reference to Pulse.dll; it subscribes to the meter named Pulse.Server, which is all System.Diagnostics.Metrics needs, and modinfo.json declares the dependency so the loader guarantees the base mod is there first.

On first boot it writes ModConfig/pulse-otlp.json:

json
{
  "Enabled": true,
  "Endpoint": "http://localhost:4318",
  "Protocol": "http/protobuf",
  "Headers": {},
  "IntervalSeconds": 60,
  "IncludeRuntimeMetrics": true,
  "ServiceName": "vintagestory"
}

Those defaults suit a collector running on the same host. Endpoint is the base address, without a signal path: Pulse appends /v1/metrics for http/protobuf and leaves it alone for grpc, where the exporter appends its own service path. Protocol takes the two names the OTLP specification defines, http/protobuf and grpc; anything else logs a warning and falls back to http/protobuf rather than leaving you with no export at all. IncludeRuntimeMetrics adds the System.Runtime meter to what gets pushed, and it is separate from the base mod's RuntimeMetrics flag, so you can serve the dotnet_* families locally and not ship them, or the other way round.

ServiceName sets the service.name resource attribute, which is how a backend receiving metrics from more than one server tells them apart: grouping, filtering and dashboard variables are usually keyed off it. The OTEL_SERVICE_NAME environment variable, the ecosystem's standard override, takes precedence over this key when it is set. For a stable instance label, pair OTEL_SERVICE_NAME (not this key) with OTEL_RESOURCE_ATTRIBUTES=service.instance.id=<id>: without OTEL_SERVICE_NAME, a freshly generated id silently overrides that variable on every restart.

IntervalSeconds is floored at 5 and capped at 86400 (24 hours), the cap there so a config typo several digits too long cannot overflow the millisecond count it is converted to. Sixty is the OTLP default and the right answer for almost everyone: the interval also decides how often every observable gauge is polled, and the loaded-chunk read behind one of them is not free.

For a local collector, the whole config is the endpoint:

json
{
  "Endpoint": "http://localhost:4318",
  "Protocol": "http/protobuf"
}

A hosted backend wants an auth header. Grafana Cloud's OTLP endpoint takes HTTP basic auth, with the instance ID as the user and an access policy token as the password:

json
{
  "Endpoint": "https://otlp-gateway-prod-eu-west-2.grafana.net/otlp",
  "Protocol": "http/protobuf",
  "Headers": {
    "Authorization": "Basic MTIzNDU2OmdsY19leGFtcGxldG9rZW4="
  },
  "IntervalSeconds": 60
}

Headers goes out with every export, so anything a backend accepts works the same way: x-honeycomb-team for Honeycomb, api-key for New Relic, x-scope-orgid for a multi-tenant Mimir. Values are percent-encoded on the way into the exporter, which the OTLP header format expects, so a base64 token with +, / and = in it needs no special handling. A literal comma in a header value does not survive the trip: the exporter unescapes the whole header string before splitting it on commas, so encoding it going in does not stop it from being read as a separator coming out. Pulse checks every header for this, and for two names that collide once leading and trailing whitespace is trimmed off, before ever handing them to the exporter, and refuses to start exporting rather than let either reach it: the server log names the offending header, never its value.

A collector that is down, refusing, or answering 401 still costs you nothing on the game side: the OpenTelemetry SDK exports from its own background thread and the tick loop never sees the failure. It no longer stays invisible, though. Pulse OTLP listens to the SDK's own diagnostic event source and turns the first failure of each kind into one line in the server log, repeated at most every ten minutes and logged at Warning rather than Error so a struggling backend can never count toward DieAboveErrorCount:

Pulse OTLP export to https://otlp-gateway-prod-eu-west-2.grafana.net/otlp/v1/metrics failed: Response status code does not indicate success: 401 (Unauthorized). The backend answered: {"status":"error","error":"authentication error: invalid token"} Metrics are not reaching the backend; check Endpoint and Headers in pulse-otlp.json. This is logged again at most every 10 minutes.

A matching line reports the first successful export after a failure, so recovery shows up too, and a healthy server that has never failed still logs exactly one such line, at Notification rather than Warning, right after its first delivery.

Neither line is meant to carry a header value, or the query string or userinfo half of Endpoint either, for a backend that authenticates a signed URL that way instead of through a header. Before a line is queued, each of those values, at least 6 characters long (shorter than that reads as an ordinary id, not a credential), the credential half of it when the value has a "scheme credential" shape (a Bearer token echoed without its "Bearer ", say), and the JSON-escaped form of both, are matched case-insensitively and redacted out of the backend's answer and out of a gRPC failure's status detail, longest value first so a short one can never land inside a longer one's own match. Anything else shaped like a bearer or basic credential of at least 8 characters is redacted too, whether or not it matches a configured value. This is not exhaustive: a backend that transforms a secret some other way, hashing it or splitting it across two fields, could still get it into the log, so treat the log itself as sensitive before sharing it regardless. The backend's answer, once redacted, is clipped to 200 characters.

A malformed Endpoint, and a Headers entry with a comma in its value or a name that collides with another once trimmed, are cases Pulse checks itself before the exporter is ever built: it logs one error, naming the problem and never the value, and registers nothing. An IntervalSeconds so large it would once have overflowed the millisecond conversion is not one of those cases any more: it is silently clamped to 86,400 seconds (24 hours) and exported at that rate instead, with no error at all. A Headers shape neither check above names, such as a comma inside a header name rather than its value, or a header called User-Agent (which collides with one the exporter sets on its own), still reaches the OpenTelemetry SDK's own option validation, which throws; Pulse catches that too, so nothing crashes and nothing is exported, but the log line only names the exception type, not the header. For anything these lines do not explain, the SDK's own, far more verbose self-diagnostics turn on by dropping an OTEL_DIAGNOSTICS.json file next to the server.

Source: README.md