OpenTelemetry
This server can export traces, metrics and logs over OTLP to a collector you run. It is off by default.
Turning it on
Section titled “Turning it on”gitlab-mcp-server --http --gitlab-url=https://gitlab.com --telemetry{ "mcpServers": { "gitlab": { "command": "gitlab-mcp-server", "env": { "GITLAB_URL": "https://gitlab.com", "GITLAB_TOKEN": "glpat-…", "GITLAB_MCP_TELEMETRY": "true", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318" } } }}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.
Three traps in the standard variables
Section titled “Three traps in the standard variables”These are not this server’s doing, they catch people, and each one fails quietly.
Authenticating to your collector
Section titled “Authenticating to your collector”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 endpointon 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 wants | What to set |
|---|---|
| Nothing (the default) | Set nothing. A supported configuration, not a fallback. |
| A bearer token | OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20YOUR_TOKEN |
| Basic auth | OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20BASE64 |
| A vendor API key | OTEL_EXPORTER_OTLP_HEADERS=api-key=YOUR_KEY |
| Several at once | OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20tok,x-tenant=acme |
| Mutual TLS | OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE and OTEL_EXPORTER_OTLP_CLIENT_KEY |
| A private CA | OTEL_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.
What is recorded
Section titled “What is recorded”One span per MCP request, plus one child span per GitLab API call it caused.
| Attribute | On | Meaning |
|---|---|---|
mcp.method.name | every span | tools/call, resources/read, prompts/get, … |
gen_ai.tool.name | tool calls | the tool the client named |
gitlab_mcp.action | tool calls | the canonical catalog action, such as issue.list |
gitlab_mcp.tool_surface | every span | dynamic, meta or individual |
network.transport | every span | pipe for stdio, tcp for HTTP |
gen_ai.prompt.name | prompts/get | the prompt the client named |
mcp.protocol.version | when it is known | the MCP revision the request is speaking |
mcp.session.id | stateful sessions only | absent under the default stateless HTTP transport |
error.type | failures only | a JSON-RPC code, or tool_error |
rpc.response.status_code | when a code was returned | the JSON-RPC code |
gitlab_mcp.domain | tool calls | the catalog domain, such as issue; bounded, so it survives on metrics where the action id is too many values |
gitlab_mcp.refusal_reason | refusals | why 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.
Metrics
Section titled “Metrics”| Instrument | Unit | What it answers |
|---|---|---|
mcp.server.operation.duration | s | how long requests take, by method and action |
mcp.client.operation.duration | s | how long this server waits on the client |
http.client.request.duration | s | how long GitLab takes |
mcp.server.session.duration | s | how 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_reason | Why |
|---|---|
safe_mode | safe mode answered a write with a preview |
needs_confirmation | a destructive action was not confirmed |
invalid_params | the parameters do not fit the action |
unknown_action | no action by that name exists on this surface |
rate_limited | the 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.
| Setting | What it does |
|---|---|
GITLAB_MCP_TELEMETRY_IDENTITY_KEY | a secret every replica derives its keys from, so one caller carries one digest across the whole deployment and across restarts |
GITLAB_MCP_TELEMETRY_IDENTITY_ROTATION | how 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.
What setting the key means
Section titled “What setting the key means”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.
What is never recorded
Section titled “What is never recorded”Not by default and not by any setting, because there is no setting:
- Tool arguments and results. The conventions define
Opt-Inattributes 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.
Recording who made a call
Section titled “Recording who made a call”Off by default, in three steps:
--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.nameor 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.
Turning it off
Section titled “Turning it off”--telemetry=false # do not turn it on; this is the defaultOTEL_SDK_DISABLED=true # veto it from the environment, whatever the flag saysOTEL_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.
Seeing whether it is on
Section titled “Seeing whether it is on”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.
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.
When it goes wrong
Section titled “When it goes wrong”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.
GITLAB_MCP_LOG_LEVEL=debug gitlab-mcp-server --telemetry 2>telemetry.logExport failures, malformed header pairs, unparseable durations and protocol warnings all arrive there as structured records.
Standards this follows
Section titled “Standards this follows”| Area | Source |
|---|---|
| Configuration variables | OTLP Exporter Configuration |
| Span and metric shape | MCP semantic conventions |
| Attribute requirement levels | Attribute Requirement Level |
| Error recording | Recording Errors |
| Identity attributes | User 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.