Skip to content

OpenTelemetry

This server can export traces, metrics and logs over OTLP to a collector you run. It is off by default.

Terminal window
gitlab-mcp-server --http --gitlab-url=https://gitlab.com --telemetry

That is the whole surface this project owns. The endpoint, the credentials, the sampling, the batching and the resource attributes all come from the standard OTEL_* environment variables the OpenTelemetry exporters read themselves. Reinventing that surface would mean maintaining a second, worse copy of a configuration you already know, and it would break the ordinary case of a host that exports OTEL_EXPORTER_OTLP_ENDPOINT once for every service on it.

These are not this server’s doing, they catch people, and each one fails quietly.

There is no username and password option, and none is missing: the specification defines no such variable. Every scheme is a header.

This server does not refuse that configuration, because a collector on a trusted private network reached over plaintext is a real deployment and the endpoint is yours to choose. It does say so once at startup, naming the signals affected:

level=WARN msg="a collector credential is configured against a plaintext endpoint
on another host; it crosses the network in the clear on every export" signals=[traces]

Loopback is exempt: a credential that never leaves the machine cannot be observed on a network, so a sidecar collector raises nothing.

What your collector wantsWhat to set
Nothing (the default)Set nothing. A supported configuration, not a fallback.
A bearer tokenOTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20YOUR_TOKEN
Basic authOTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20BASE64
A vendor API keyOTEL_EXPORTER_OTLP_HEADERS=api-key=YOUR_KEY
Several at onceOTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20tok,x-tenant=acme
Mutual TLSOTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE and OTEL_EXPORTER_OTLP_CLIENT_KEY
A private CAOTEL_EXPORTER_OTLP_CERTIFICATE

This server never transforms that variable. Whatever you set is what your collector receives, and it is never logged: a test asserts the credential never appears in this server’s own log output, including when an export fails.

It is read, once, and only in order to keep it out of that output. The SDK’s own header parser prints what it could not parse, and the log exporter hands the entire variable to the error handler, so one malformed non-credential pair is enough to print a perfectly well formed credential beside it. This server reads the same variables the exporters do at startup and redacts those values, and their percent-decoded spellings, out of every SDK diagnostic it emits.

One span per MCP request, plus one child span per GitLab API call it caused.

AttributeOnMeaning
mcp.method.nameevery spantools/call, resources/read, prompts/get, …
gen_ai.tool.nametool callsthe tool the client named
gitlab_mcp.actiontool callsthe canonical catalog action, such as issue.list
gitlab_mcp.tool_surfaceevery spandynamic, meta or individual
network.transportevery spanpipe for stdio, tcp for HTTP
gen_ai.prompt.nameprompts/getthe prompt the client named
mcp.protocol.versionwhen it is knownthe MCP revision the request is speaking
mcp.session.idstateful sessions onlyabsent under the default stateless HTTP transport
error.typefailures onlya JSON-RPC code, or tool_error
rpc.response.status_codewhen a code was returnedthe JSON-RPC code
gitlab_mcp.domaintool callsthe catalog domain, such as issue; bounded, so it survives on metrics where the action id is too many values
gitlab_mcp.refusal_reasonrefusalswhy the call was declined without reaching GitLab

gitlab_mcp.action is the one worth understanding. On the default dynamic surface every tool call names the same two tools, so gen_ai.tool.name is gitlab_execute_action whether the server listed issues or deleted a branch. The action attribute is the only thing that tells them apart.

InstrumentUnitWhat it answers
mcp.server.operation.durationshow long requests take, by method and action
mcp.client.operation.durationshow long this server waits on the client
http.client.request.durationshow long GitLab takes
mcp.server.session.durationshow long a client stays connected
gitlab_mcp.credential_pool.entries{entry}how many credentials the pool holds (HTTP mode)
gitlab_mcp.credential_pool.capacity{entry}how many it may hold, which is --max-http-clients
gitlab_mcp.credential_pool.evictions{eviction}entries dropped, split by reason

mcp.server.session.duration is deliberately not recorded under the default stateless HTTP transport, where each POST is its own session: the histogram would be a copy of mcp.server.operation.duration under a name promising something else.

The three credential-pool instruments carry this server’s own namespace rather than the convention’s mcp., because a credential pool is this deployment’s concept and not the protocol’s. gitlab_mcp.credential_pool.eviction.reason takes one of seven values: size_pressure, size_pressure_busy, idle, stale_credential, rejected_credential, invalid_credential and rebuild. All seven are exported from process start, zeros included, so a panel is never empty for the ambiguous reason.

size_pressure_busy is the one to alert on: it is the only path that ends a subscription somebody is waiting on, it means the pool held nothing quiet to take, and it has a matching WARN line carrying in_use=true and the max_size to raise.

One attribute the convention asks for is missing, and it cannot be supplied: jsonrpc.request.id. The Go SDK gives a receiving middleware no access to the JSON-RPC id of the message it is handling, so there is nothing to record.

Seconds, not milliseconds. The convention fixes that, and it is the opposite of the duration field in this server’s log records.

Two of those dimensions carry a name the caller chose, gen_ai.tool.name and gen_ai.prompt.name. On a metric they are bounded: a call that named something this server does not have is recorded as _OTHER rather than under the name it sent, so the number of stored time series follows what is registered rather than what a client types. The span keeps the name verbatim, which is where you look when a client reports that its calls are failing.

A refusal is not a malfunction, which is why it has an attribute of its own: error.type cannot tell a call this server declined from one that broke. The call arrived well formed and this server chose not to run it:

gitlab_mcp.refusal_reasonWhy
safe_modesafe mode answered a write with a preview
needs_confirmationa destructive action was not confirmed
invalid_paramsthe parameters do not fit the action
unknown_actionno action by that name exists on this surface
rate_limitedthe deployment’s own limit rejected the call

Those five are the whole set, which is what makes the attribute affordable as a metric dimension as well as a span attribute: a deployment refusing every third call looks exactly like a healthy one on a duration histogram alone.

Some of them carry error.type as well, and that is not a contradiction. A refusal the caller can fix is answered with an error result, so the model is told plainly that its call failed; safe mode answers with a preview instead, which is a successful result and sets no error.type. Group by the refusal reason rather than by error.type when you want the declined calls.

rate_limited is the one worth alerting on, because it is the only refusal that is not the caller’s doing. Its log line is throttled to one every ten seconds, so a client in a retry loop cannot flood the terminal; the metric counts them all.

The third signal is this server’s own structured records: the same lines it writes to stderr, exported from INFO upward. GITLAB_MCP_LOG_LEVEL still governs the terminal; the export floor is separate, so running at debug does not send a record per GitLab round trip to your collector on top of the span that already describes it.

Every record written while serving a request carries the trace and span id of that request, so a backend can jump from a slow span straight to the lines written inside it. Startup, shutdown and background records carry none, because there is no span to belong to.

The identity policy governs these records as well, and the two legs differ on purpose. Your terminal keeps user and user_id whatever the policy says: you are reading your own server’s output, and a setting about what leaves the process has no business editing that. The exported copy carries what the policy allows and under the same user.* names the spans use, so one query joins them.

That last paragraph was untrue until recently, and a live deployment is what showed it: running on pseudonymous, which names nobody, its spans carried a digest and its logs carried the username in the clear. The policy had been applied where it was written and never to the log bridge.

By default the digest is keyed with a secret generated at startup and written nowhere, so it identifies a caller within one process and nowhere else. A deployment running several replicas gives the same person a different digest on each, at the same time, and a restart renumbers everybody. For a single instance that is usually what you want: nothing to store, nothing to leak.

Two settings change it, and which one you reach for follows from how you run the server.

SettingWhat it does
GITLAB_MCP_TELEMETRY_IDENTITY_KEYa secret every replica derives its keys from, so one caller carries one digest across the whole deployment and across restarts
GITLAB_MCP_TELEMETRY_IDENTITY_ROTATIONhow long a generated key lives, such as 24h; empty keeps it for the life of the process

Set the key when you run more than one replica, or when a count of distinct users has to survive a deploy. Rotation then does not apply: a key you supplied is yours to rotate, on your schedule, and this server says so at startup rather than rotating it underneath you.

Set the rotation interval when you run one instance and want the pseudonym to stop correlating after a while. It is off by default because replicas start at different moments, so they would rotate out of phase and a distinct-user count would churn without anybody asking for it.

Nothing prescribes either answer. The OpenTelemetry registry defines user.hash as a value “to correlate information for a user in anonymized form” and says nothing about how long it should hold, and neither the specification nor ENISA offers guidance on the lifetime of a pseudonymisation secret. What the field shows is two coherent designs, and Matomo ships both at once: an installation-wide salt that never rotates where a pseudonym must persist, and a seed discarded every day where it must not.

Say it plainly, because it is a real change in what the export is. A stable pseudonym is what the EDPB calls a person pseudonym, which “requires long-term storage of the pseudonymisation secrets” and whose “risk of unauthorised attribution is comparatively high”. Under GDPR Article 4(5) that key is the “additional information” that allows attribution, so it has to be kept separately from the data it protects.

Concretely: GitLab user ids are small integers, so anyone holding both the key and an export recovers the mapping by enumeration, in about two minutes on one core. The key must not live where the telemetry lands. An environment variable is where this server reads it, with the caveat every environment secret carries: it is visible to anything that can read the process environment.

The key is expanded with HKDF-SHA256 into two independent keys, one for callers and one for resource URIs, so the value you supply is never used as a key itself and a digest of a user cannot be compared against a digest of a resource.

Not by default and not by any setting, because there is no setting:

  • Tool arguments and results. The conventions define Opt-In attributes for both; this server declines them, because they carry project paths, issue bodies and search queries.
  • Resource contents. The URI that named the resource follows the identity policy: a keyed per-process digest by default, and the URI itself only under full, which already exports the caller’s real name.
  • Your GitLab token, or any header a client sent.
  • GitLab response bodies and error messages. A failure records a classification such as -32603, never the text, which can name private paths.
  • Full URLs of GitLab calls. The child span records the method, the host and the status; the parent span already names the action, which identifies the endpoint family more legibly than a URL would.

An end-to-end test drives real traffic carrying a distinctive project path, a search query and a token, then searches every exported payload for all three.

Off by default, in three steps:

Terminal window
--telemetry-identity none # the default: nothing about the caller
--telemetry-identity pseudonymous # a per-process digest: correlatable, not readable
--telemetry-identity full # user.id and user.name

or GITLAB_MCP_TELEMETRY_IDENTITY.

The policy governs one more thing than its name suggests: which resource a request named. A resource URI here embeds project and group ids, so it says what a caller is working on, which is the same class of disclosure as saying who they are. Under none and pseudonymous a span carries gitlab_mcp.resource.ref, a keyed per-process digest that correlates reads and polls of one resource without naming it. Under full it carries mcp.resource.uri, the convention’s own attribute, with the URI in it.

One flag rather than two, because a deployment that recorded project paths while claiming to record nobody would be the predictable result of letting the two settings drift apart.

none is the default because whether to record a person’s identity is your decision about your own users, and a default that decides for you is wrong whichever way it points. The conventions agree: these attributes are Opt-In, whose rule is “Instrumentations SHOULD populate the attribute if and only if the user configures the instrumentation to do so”.

pseudonymous emits user.hash, an HMAC-SHA256 digest under a 32-byte key generated at startup and never written anywhere. It gives a shared endpoint the one thing it genuinely needs, telling one caller’s traffic from another’s, so a burst can be attributed and a session followed, without naming anybody.

full emits user.id and user.name. What an organization auditing its own users wants, and what it is entitled to: those are its people and its collector.

None of these ever reach a metric, under any policy. A span carrying a user id costs one span; a metric dimension carrying one is a time series per person, growing without bound with the number of people using the deployment.

Terminal window
--telemetry=false # do not turn it on; this is the default
OTEL_SDK_DISABLED=true # veto it from the environment, whatever the flag says

OTEL_SDK_DISABLED is the specification’s own kill switch, and this server honors it where nothing beneath it does: the string appears in no OpenTelemetry Go module. It is a veto rather than a switch, because its specified default means “enabled” while telemetry here is off until asked for. Setting it to true forces no-op providers even when --telemetry was passed, which is what lets you disable telemetry across a fleet without editing every unit file.

Its boolean grammar is the specification’s and is stricter than Go’s: only the case-insensitive string true disables. 1, t and yes do not, and an empty value counts as unset.

The enumerating server card reports it, at the .well-known path. The SEP-2127 card at /server-card states identity and connection details only, and carries no capability blocks.

Terminal window
curl -s http://localhost:8080/.well-known/mcp/server-card.json | jq .telemetry
{
"enabled": true,
"signals": ["traces", "metrics", "logs"],
"protocol": "http/protobuf",
"conventions": "OpenTelemetry, following the MCP semantic convention",
"recorded": "the method called, the tool and catalog action, the outcome and the duration",
"not_recorded": "tool arguments, tool results, resource contents, queries, tokens, and GitLab response bodies"
}

The block is absent when telemetry is off, rather than present and saying false: you should not have to parse a negation to learn that nothing is being recorded. The collector address is deliberately not published there, because it names your infrastructure and the card is served to anyone who asks.

Telemetry failures never affect a request. A collector that is down, refusing connections or rejecting your credential produces log lines on stderr and changes nothing else: the same status, the same body, the same latency. The specification leaves this choice open (“The API or SDK MAY fail fast … but MUST NOT cause the application to fail later at runtime”), and the choice here is that a server which can talk to GitLab keeps doing so when it cannot talk to a collector.

Terminal window
GITLAB_MCP_LOG_LEVEL=debug gitlab-mcp-server --telemetry 2>telemetry.log

Export failures, malformed header pairs, unparseable durations and protocol warnings all arrive there as structured records.

AreaSource
Configuration variablesOTLP Exporter Configuration
Span and metric shapeMCP semantic conventions
Attribute requirement levelsAttribute Requirement Level
Error recordingRecording Errors
Identity attributesUser attributes

The MCP semantic convention is Development status, which its own maturity table describes as “SHOULD NOT be used in production” and “MAY be removed without prior notice”. It is adopted anyway, on the grounds that it affects operators rather than MCP clients: a convention change costs a dashboard edit and costs consumers of this server nothing. It lives in a separate repository rather than on opentelemetry.io, whose /docs/specs/semconv/mcp/ returns 404.