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.
Four traps in the standard variables
Section titled “Four traps in the standard variables”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.
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.
| 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%20$(printf 'user:pass' | base64) |
| 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=/path/cert.pem and OTEL_EXPORTER_OTLP_CLIENT_KEY=/path/key.pem |
| A private CA | OTEL_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.
What is recorded
Section titled “What is recorded”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.
| 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.
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.
Metrics
Section titled “Metrics”| Instrument | Unit | What it answers |
|---|---|---|
mcp.server.operation.duration | s | how long requests take, by method, domain and, where the tool name is kept, action |
mcp.client.operation.duration | s | how long this server waits on the client for what it initiates (elicitation, sampling, roots/list, notifications) |
http.client.request.duration | s | how long GitLab takes |
mcp.server.session.duration | s | how 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.duration | s | how 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:
| Reason | What happened |
|---|---|
size_pressure | The pool was full and dropped the least recently used entry that was doing nothing |
size_pressure_busy | The 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_credential | The credential had not been checked against GitLab inside the one-hour age ceiling, so the entry is rebuilt rather than trusted |
rejected_credential | GitLab 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_credential | Periodic revalidation found GitLab refusing the credential |
below_minimum | GitLab 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) |
rebuild | The 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 subscriptionEntries 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_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 |
fine_grained | a 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.
| 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.
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.
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, 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
Hostheader 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.
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. 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.
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 |
Trace context in _meta | MCP 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.