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, and they catch people. Three of them leave telemetry running on something other than what you meant; the fourth, http/json, is refused at startup with an ERROR line, telemetry disabled: it could not be started, and the server goes on serving without telemetry.

This server emulates that deviation per signal rather than reading the scheme off the URL, so the plaintext warning names the signals that are actually affected. It does not refuse the configuration: the endpoint is yours to choose.

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

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%20$(printf 'user:pass' | base64)
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=/path/cert.pem and OTEL_EXPORTER_OTLP_CLIENT_KEY=/path/key.pem
A private CAOTEL_EXPORTER_OTLP_CERTIFICATE=/path/ca.pem

A header configured for a signal whose endpoint is plaintext and on another host, either an http:// endpoint or an https:// one that OTEL_EXPORTER_OTLP_INSECURE downgrades as described above, crosses the network in the clear on every export. 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","component":"telemetry","signals":["traces"]}

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

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. The variable name is left in place, because an operator whose configuration is malformed needs to know which one to fix.

One span per MCP request, plus one child span per GitLab API call it caused. HTTP mode adds one span per inbound HTTP request, described after the table.

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.

The child span of a GitLab call carries http.request.method, the host and port it was sent to and the response status, and, when the route is one the server’s fine-grained permission table holds, url.template: the route as GitLab declares it, such as /api/v4/projects/:id/issues, which also names the span (GET /api/v4/projects/:id/issues). The template carries the placeholders and never the values a request filled in. An instance installed under a path of its own (a relative URL root, https://host/gitlab) gets the same template: the path in front of /api/ is the instance’s and is left out. A request to a route the table does not hold, /api/v4/version among them, carries no template, and its span is named by its method alone. The template is a span attribute only: it is not a dimension of http.client.request.duration, whose series it would multiply by every route GitLab serves.

In HTTP mode every inbound HTTP request also gets a server span of its own, opened before host validation, CORS and the credential check. The MCP span starts after authentication, so this is the one that records a request refused before it reached MCP, as its status code. It is named by the method and carries http.request.method, url.scheme (read from the connection, never from X-Forwarded-Proto), network.protocol.name and http.response.status_code. A method outside the convention’s list is recorded as _OTHER, with the verb as sent, cut at 32 characters, in http.request.method_original on the span only. It records no path, because on a published endpoint the path is whatever a scanner sends. A 4xx leaves the span status unset, as the convention requires of a server span, and a 5xx marks it as an error.

InstrumentUnitWhat it answers
mcp.server.operation.durationshow long requests take, by method, domain and, where the tool name is kept, action
mcp.client.operation.durationshow long this server waits on the client for what it initiates (elicitation, sampling, roots/list, notifications)
http.client.request.durationshow long GitLab takes
mcp.server.session.durationshow long a client stays connected (stdio and --stateless=false)
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 gitlab_mcp.credential_pool.eviction.reason
http.server.request.durationshow long an inbound HTTP request takes, the credential check included and refused requests too (HTTP mode)
gitlab_mcp.auth.blocks{request}requests refused before their credential was read, split by gitlab_mcp.auth.block.reason (HTTP mode)

GITLAB_MCP_TELEMETRY_TOOL_NAME (--telemetry-tool-name) decides two metric dimensions together, gen_ai.tool.name and gitlab_mcp.action, because on the individual surface each tool is one action and keeping either keeps as many series as keeping both. auto keeps both on the dynamic and meta surfaces and drops both on individual, off drops both on every surface and on keeps both. gitlab_mcp.domain stays under every value, so a panel built on it survives, and a panel built on the action shows one series per domain once the action is dropped. Spans always carry both, whatever the setting.

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.

http.server.request.duration is measured by the HTTP span above, and its dimensions are that span’s method, scheme, protocol name and status code, the original verb left out. gitlab_mcp.auth.blocks counts each refusal under the budget that answered it, as gitlab_mcp.auth.block.reason: failure_lockout and distinct_tokens are the two authentication budgets, and transport_source is a coarser one charged to the address the connection came from rather than to the one a trusted proxy header names, which exists only when such a header is trusted. All three are exported from startup, zeros included. The address refused is deliberately not a dimension: it would make the series identify people, and grow without bound under exactly the traffic it measures.

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, and a name under mcp. would claim part of a namespace the semantic convention owns. They are published by HTTP mode only, and capacity is published beside entries so “how close to the bound” is answerable without typing the flag’s value into a dashboard query.

gitlab_mcp.credential_pool.eviction.reason takes one of eight values, one per removal path, and every one of them is exported from process start whether it has fired or not, zeros included, so a panel is never empty for the ambiguous reason:

ReasonWhat happened
size_pressureThe pool was full and dropped the least recently used entry that was doing nothing
size_pressure_busyThe pool was full, every entry was serving a subscription (a resource watcher or an open subscriptions/listen stream), and the oldest went anyway
idle--pool-idle-timeout reclaimed an entry nobody had used
stale_credentialThe credential had not been checked against GitLab inside the one-hour age ceiling, so the entry is rebuilt rather than trusted
rejected_credentialGitLab refused the credential on a call: a 401 naming the token, or a 401 the credential probe then confirmed, never a permission refusal. A GraphQL 401 to a token carrying neither api nor read_api names the token too (Refused calls)
invalid_credentialPeriodic revalidation found GitLab refusing the credential
below_minimumGitLab said of a credential the pool already serves that it is genuine and carries neither read_api nor api, the admission minimum: a confirmation or a revalidation got 403 insufficient_scope, or the token’s own description, asked again because its kind was unknown when the entry was built, named only scopes below it (Credential verification)
rebuildThe configuration shape’s catalog registration failed, taking its entries with it

size_pressure_busy is the one to alert on. Every eviction ends the subscriptions of the credential it removes, but this is the only one that takes a subscriber’s entry for want of room, its credential still good, and it means the pool held nothing quiet to take: see the lever for the value of --max-http-clients that puts it out of reach. It has a matching WARN line carrying in_use=true and the max_size to raise:

server pool: evicted an entry that was serving a subscription

Entries dropped at shutdown are deliberately uncounted: nothing observes a metric after the process ends.

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. It is registered upstream.

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
fine_graineda fine-grained token cannot run that action

Those six 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.

Under GODEBUG=fips140=only, HKDF takes no key shorter than 112 bits, so a key of fewer than 14 bytes is refused at startup: the server logs the refusal and records nothing about callers, as it does whenever it cannot derive its pseudonymisation keys. 3.1.0 stopped the process with a panic on the same key.

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, the status and, where the server’s table holds it, the route template, which names the endpoint with placeholders where the URL would carry project paths, identifiers and query values. The parent span already names the action.
  • credential_hash, the handle an HTTP client’s credential is logged by: a keyed digest of it, under a key drawn when the process starts and never written anywhere, so it carries none of the token’s characters. It keeps being written to stderr, where it is what an operator correlates a refusal by on their own terminal, and it is stripped from the exported copy of every log record at any depth: it authenticates nothing and correlates everything one caller does, which is not a distinction a telemetry backend can make for you.
  • The Host header of a request the host guard refused. It stays on stderr, where it is what you read to find out which host a misconfigured proxy is forwarding, and it is stripped from the exported copy the same way: on a published endpoint the value is whatever a caller chose to send. It reached collectors in 3.0.0, unnoticed because the test that says it cannot was winning its race against the log batch, and is stripped from 3.1.0 on.

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; another sends a spoofed Host, a spoofed X-Forwarded-For and a probe path and does the same.

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. By default its key comes from 32 random bytes generated at startup and never written anywhere; with GITLAB_MCP_TELEMETRY_IDENTITY_KEY set it is derived from that key instead (Logs has both settings). 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
Trace context in _metaMCP specification

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.