OpenTelemetry
Este servidor puede exportar trazas, métricas y logs por OTLP a un colector que tú ejecutas. Está desactivado por defecto.
Cómo activarlo
Sección titulada «Cómo activarlo»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" } } }}Esa es toda la superficie que este proyecto posee. El endpoint, las
credenciales, el muestreo, el agrupamiento y los atributos de recurso vienen de
las variables de entorno OTEL_* estándar que los exportadores de OpenTelemetry
leen por su cuenta. Reinventar esa superficie significaría mantener una segunda
copia peor de una configuración que ya conoces, y rompería el caso corriente de
una máquina que exporta OTEL_EXPORTER_OTLP_ENDPOINT una vez para todos sus
servicios.
Tres trampas de las variables estándar
Sección titulada «Tres trampas de las variables estándar»No son cosa de este servidor, pillan a mucha gente, y las tres fallan en silencio.
Autenticarse ante tu colector
Sección titulada «Autenticarse ante tu colector»No hay opción de usuario y contraseña, y no falta ninguna: la especificación no define esa variable. Todos los esquemas son cabeceras.
Este servidor no rechaza esa configuración, porque un colector en una red privada de confianza alcanzado en claro es un despliegue real y el endpoint es cosa tuya. Lo que sí hace es decirlo una vez al arrancar, nombrando las señales afectadas:
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]El bucle local queda exento: una credencial que nunca sale de la máquina no se puede observar en una red, así que un colector adyacente no genera ningún aviso.
| Lo que pide tu colector | Qué configurar |
|---|---|
| Nada (el valor por defecto) | No configures nada. Es una configuración admitida, no un recurso de última hora. |
| Un token bearer | OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20TU_TOKEN |
| Autenticación básica | OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20BASE64 |
| Una clave de API de proveedor | OTEL_EXPORTER_OTLP_HEADERS=api-key=TU_CLAVE |
| Varias a la vez | OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20tok,x-tenant=acme |
| TLS mutuo | OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE y OTEL_EXPORTER_OTLP_CLIENT_KEY |
| Una CA privada | OTEL_EXPORTER_OTLP_CERTIFICATE |
Este servidor nunca transforma esa variable, y nunca la registra. Lo que configures es lo que recibe tu colector, y una prueba de extremo a extremo verifica que la credencial no aparece nunca en los logs del propio servidor, ni siquiera cuando falla una exportación.
Sí la lee, una vez, y solo para mantenerla fuera de esa salida. El analizador de cabeceras del SDK imprime lo que no supo interpretar, y el exportador de logs entrega la variable entera al manejador de errores, así que un solo par malformado que no sea la credencial basta para imprimir al lado una credencial perfectamente formada. Este servidor lee al arrancar las mismas variables que leen los exportadores y redacta esos valores, y sus formas decodificadas, de cada diagnóstico del SDK que emite.
Qué se registra
Sección titulada «Qué se registra»Un span por petición MCP, más un span hijo por cada llamada a la API de GitLab que provoque.
| Atributo | Dónde | Significado |
|---|---|---|
mcp.method.name | todos los spans | tools/call, resources/read, prompts/get, … |
gen_ai.tool.name | llamadas a herramienta | la herramienta que nombró el cliente |
gitlab_mcp.action | llamadas a herramienta | la acción canónica del catálogo, como issue.list |
gitlab_mcp.tool_surface | todos los spans | dynamic, meta o individual |
network.transport | todos los spans | pipe en stdio, tcp en HTTP |
gen_ai.prompt.name | prompts/get | el prompt que nombró el cliente |
mcp.protocol.version | cuando se conoce | la revisión de MCP que habla la petición |
mcp.session.id | solo sesiones con estado | ausente en el HTTP sin estado por defecto |
error.type | solo en fallos | un código JSON-RPC, o tool_error |
rpc.response.status_code | cuando hubo código | el código JSON-RPC |
gitlab_mcp.domain | llamadas a herramientas | el dominio del catálogo, como issue; acotado, así que sobrevive en métricas donde el id de acción son demasiados valores |
gitlab_mcp.refusal_reason | rechazos | por qué se declinó la llamada sin llegar a GitLab |
gitlab_mcp.action es el que conviene entender. En la superficie dinámica, que
es la de por defecto, cada llamada nombra las mismas dos herramientas, así que
gen_ai.tool.name vale gitlab_execute_action tanto si el servidor listó
incidencias como si borró una rama. El atributo de acción es lo único que las
distingue.
Métricas
Sección titulada «Métricas»| Instrumento | Unidad | Qué responde |
|---|---|---|
mcp.server.operation.duration | s | cuánto tardan las peticiones, por método y acción |
mcp.client.operation.duration | s | cuánto espera este servidor al cliente |
http.client.request.duration | s | cuánto tarda GitLab |
mcp.server.session.duration | s | cuánto sigue conectado un cliente |
gitlab_mcp.credential_pool.entries | {entry} | cuántas credenciales tiene el pool (modo HTTP) |
gitlab_mcp.credential_pool.capacity | {entry} | cuántas puede tener, que es --max-http-clients |
gitlab_mcp.credential_pool.evictions | {eviction} | entradas soltadas, separadas por razón |
mcp.server.session.duration no se registra a propósito en el HTTP sin estado
por defecto, donde cada POST es su propia sesión: el histograma sería una copia
de mcp.server.operation.duration con un nombre que promete otra cosa.
Los tres instrumentos del pool de credenciales llevan el espacio de nombres
propio de este servidor en lugar del mcp. de la convención, porque un pool de
credenciales es un concepto de este despliegue y no del protocolo.
gitlab_mcp.credential_pool.eviction.reason toma uno de siete valores:
size_pressure, size_pressure_busy, idle, stale_credential,
rejected_credential, invalid_credential y rebuild. Los siete se exportan
desde el arranque del proceso, ceros incluidos, para que un panel nunca esté
vacío por el motivo ambiguo.
size_pressure_busy es el que merece una alerta: es el único camino que termina
una suscripción que alguien está esperando, significa que el pool no tenía nada
tranquilo que llevarse, y tiene una línea WARN propia con in_use=true y el
max_size que hay que subir.
Falta un atributo que la convención pide y que no se puede aportar:
jsonrpc.request.id. El SDK de Go no da a un middleware receptor ningún acceso
al identificador JSON-RPC del mensaje que atiende, así que no hay nada que
registrar.
Segundos, no milisegundos. Lo fija la convención, y es lo contrario del campo
duration de los registros de log de este servidor.
Dos de esas dimensiones llevan un nombre que eligió el llamante,
gen_ai.tool.name y gen_ai.prompt.name. En una métrica van acotadas: una
llamada que nombró algo que este servidor no tiene se registra como _OTHER y
no bajo el nombre que envió, así que el número de series almacenadas depende de
lo que hay registrado y no de lo que teclee un cliente. El span conserva el
nombre literal, que es donde hay que mirar cuando un cliente informa de que sus
llamadas fallan.
Un rechazo no es una avería, y por eso tiene un atributo propio: error.type no
distingue una llamada que este servidor declinó de una que se rompió. La llamada
llegó bien formada y este servidor decidió no ejecutarla:
gitlab_mcp.refusal_reason | Por qué |
|---|---|
safe_mode | el modo seguro respondió una escritura con una previsualización |
needs_confirmation | una acción destructiva sin confirmar |
invalid_params | los parámetros no encajan con la acción |
unknown_action | no existe ninguna acción con ese nombre en esta superficie |
rate_limited | el propio límite de tasa del despliegue rechazó la llamada |
Esos cinco son el conjunto completo, y eso es lo que hace que el atributo sea asumible también como dimensión de métrica: un despliegue que rechaza una de cada tres llamadas es indistinguible de uno sano si solo se mira el histograma de duración.
Algunos llevan además error.type, y no es una contradicción. Un rechazo que
quien llama puede corregir se responde con un resultado de error, para que el
modelo sepa sin ambigüedad que su llamada falló; el modo seguro responde con una
previsualización, que es un resultado correcto y no fija ningún error.type.
Para quedarte con las llamadas declinadas, agrupa por el motivo de rechazo y no
por error.type.
rate_limited es el que conviene vigilar, porque es el único rechazo que no
depende de quien llama. Su línea de log se limita a una cada diez segundos, para
que un cliente en bucle de reintento no inunde el terminal; la métrica las cuenta
todas.
Registros
Sección titulada «Registros»La tercera señal son los registros estructurados del propio servidor: las mismas
líneas que escribe en stderr, exportadas desde INFO hacia arriba. GITLAB_MCP_LOG_LEVEL
sigue gobernando el terminal; el umbral de exportación es aparte, así que
ejecutar en debug no manda a tu colector un registro por cada viaje a GitLab
además del span que ya lo describe.
Cada registro escrito mientras se atiende una petición lleva el identificador de traza y de span de esa petición, así que desde un span lento se puede saltar directamente a las líneas que se escribieron dentro. Los registros de arranque, apagado y tareas de fondo no llevan ninguno, porque no hay span al que pertenecer.
La política de identidad gobierna también estos registros, y las dos patas se
comportan distinto a propósito. Tu terminal conserva user y user_id diga lo
que diga la política: estás leyendo la salida de tu propio servidor, y un ajuste
sobre lo que sale del proceso no tiene por qué editarla. La copia exportada
lleva lo que la política permite, con los mismos nombres user.* que usan los
spans, para que una sola consulta los cruce.
Ese último párrafo no era cierto hasta hace poco, y lo enseñó un despliegue en
marcha: con la política en pseudonymous, que no nombra a nadie, sus spans
llevaban un digest y sus logs el nombre de usuario en claro. La política se
había aplicado donde se escribió y nunca al puente de logs.
Por defecto el digest se calcula con un secreto generado al arrancar y que no se escribe en ningún sitio, así que identifica a quien llama dentro de un proceso y en ningún otro lado. Un despliegue con varias réplicas le da a la misma persona un digest distinto en cada una, a la vez, y un reinicio renumera a todo el mundo. Para una instancia única eso suele ser lo que quieres: nada que guardar, nada que filtrar.
Dos ajustes lo cambian, y cuál te toca se deduce de cómo ejecutas el servidor.
| Ajuste | Qué hace |
|---|---|
GITLAB_MCP_TELEMETRY_IDENTITY_KEY | un secreto del que cada réplica deriva sus claves, para que quien llama lleve un único digest en todo el despliegue y entre reinicios |
GITLAB_MCP_TELEMETRY_IDENTITY_ROTATION | cuánto vive una clave generada, por ejemplo 24h; vacío la mantiene lo que dure el proceso |
Pon la clave si ejecutas más de una réplica, o si el recuento de usuarios distintos tiene que sobrevivir a un despliegue. La rotación entonces no aplica: una clave que pusiste tú es tuya y la rotas en tu calendario, y este servidor lo dice al arrancar en vez de rotarla por debajo.
Pon el intervalo de rotación si ejecutas una sola instancia y quieres que el seudónimo deje de correlacionar pasado un tiempo. Viene desactivado porque las réplicas arrancan en instantes distintos, así que rotarían desfasadas y el recuento de usuarios se movería sin que nadie lo haya pedido.
Ninguna de las dos respuestas está prescrita. La convención define user.hash
como un valor “to correlate information for a user in anonymized form” y no dice
nada sobre cuánto debe durar, y ni la especificación ni ENISA dan guía sobre la
vida de un secreto de seudonimización. Lo que sí hay son dos diseños coherentes,
y Matomo lleva los dos a la vez: una sal de la instalación que no rota donde el
seudónimo debe persistir, y una semilla que se descarta cada día donde no debe.
Qué implica poner la clave
Sección titulada «Qué implica poner la clave»Sin adornos, porque cambia de verdad lo que es el export. Un seudónimo estable es lo que el EDPB llama person pseudonym, que “requires long-term storage of the pseudonymisation secrets” y cuyo “risk of unauthorised attribution is comparatively high”. Según el Artículo 4(5) del RGPD esa clave es la “additional information” que permite la atribución, así que hay que guardarla por separado de los datos que protege.
En concreto: los ids de usuario de GitLab son enteros pequeños, así que cualquiera que tenga la clave y un export recupera la correspondencia por enumeración, en unos dos minutos y en un solo núcleo. La clave no puede vivir donde aterriza la telemetría. Este servidor la lee de una variable de entorno, con la advertencia que arrastra todo secreto en el entorno: es visible para cualquier cosa que pueda leer el entorno del proceso.
La clave se expande con HKDF-SHA256 en dos claves independientes, una para quien llama y otra para las URIs de recurso, así que el valor que pones nunca se usa como clave directamente y un digest de usuario no se puede comparar con uno de recurso.
Qué no se registra nunca
Sección titulada «Qué no se registra nunca»Ni por defecto ni con ningún ajuste, porque no existe tal ajuste:
- Argumentos y resultados de las herramientas. Las convenciones definen
atributos
Opt-Inpara ambos; este servidor los declina, porque llevan rutas de proyecto, cuerpos de incidencia y consultas de búsqueda. - El contenido de los recursos. El URI que nombró el recurso sigue la
política de identidad: un digest con clave por proceso por defecto, y el URI
literal solo bajo
full, que ya exporta el nombre real de quien llama. - Tu token de GitLab, ni ninguna cabecera que enviara un cliente.
- Cuerpos y mensajes de error de GitLab. Un fallo registra una clasificación
como
-32603, nunca el texto, que puede nombrar rutas privadas. - URL completas de las llamadas a GitLab. El span hijo registra el método, el host y el estado; el span padre ya nombra la acción, que identifica la familia de endpoints de forma más legible que una URL.
Una prueba de extremo a extremo lanza tráfico real con una ruta de proyecto distintiva, una consulta de búsqueda y un token, y luego busca los tres en cada payload exportado.
Registrar quién hizo la llamada
Sección titulada «Registrar quién hizo la llamada»Desactivado por defecto, en tres escalones:
--telemetry-identity none # el valor por defecto: nada sobre quien llama--telemetry-identity pseudonymous # un resumen por proceso: correlacionable, no legible--telemetry-identity full # user.id y user.nameo GITLAB_MCP_TELEMETRY_IDENTITY.
La política gobierna una cosa más de lo que sugiere su nombre: qué recurso
nombró la petición. Un URI de recurso aquí lleva ids de proyecto y de grupo,
así que dice en qué está trabajando quien llama, que es la misma clase de
revelación que decir quién es. Con none y pseudonymous el span lleva
gitlab_mcp.resource.ref, un digest con clave por proceso que correlaciona
lecturas y sondeos de un mismo recurso sin nombrarlo. Con full lleva
mcp.resource.uri, el atributo de la propia convención, con el URI dentro.
Una bandera y no dos, porque un despliegue que registrara rutas de proyecto mientras dice no registrar a nadie sería el resultado previsible de dejar que las dos configuraciones se separen.
none es el valor por defecto porque registrar la identidad de una persona
es una decisión tuya sobre tus propios usuarios, y un valor por defecto que la
tome por ti es equivocado apunte hacia donde apunte. Las convenciones coinciden:
estos atributos son Opt-In, y su regla dice que “las instrumentaciones
DEBERÍAN rellenar el atributo si y solo si el usuario configura la
instrumentación para hacerlo”.
pseudonymous emite user.hash, un resumen HMAC-SHA256 bajo una clave de
32 bytes generada al arrancar y que no se escribe en ningún sitio. Le da a un
endpoint compartido lo único que necesita de verdad, distinguir el tráfico de un
llamante del de otro, para poder atribuir una ráfaga y seguir una sesión, sin
nombrar a nadie.
full emite user.id y user.name. Lo que quiere una organización que
audita a sus propios usuarios, y a lo que tiene derecho: son su gente y su
colector.
Ninguno de estos llega nunca a una métrica, con ninguna política. Un span que lleva un identificador de usuario cuesta un span; una dimensión de métrica que lo lleva es una serie temporal por persona, que crece sin límite con el número de personas que usan el despliegue.
Cómo desactivarlo
Sección titulada «Cómo desactivarlo»--telemetry=false # no lo actives; es el valor por defectoOTEL_SDK_DISABLED=true # vétalo desde el entorno, diga lo que diga la banderaOTEL_SDK_DISABLED es el interruptor de apagado de la propia especificación, y
este servidor lo honra donde nada por debajo lo hace: la cadena no aparece en
ningún módulo de OpenTelemetry para Go. Es un veto y no un interruptor,
porque su valor por defecto significa “activado” mientras que aquí la telemetría
está apagada hasta que se pide. Ponerlo a true fuerza proveedores inertes
aunque se haya pasado --telemetry, que es lo que permite desactivar la
telemetría en toda una flota sin editar cada fichero de servicio.
Su gramática booleana es la de la especificación y es más estricta que la de Go:
solo la cadena true, sin distinguir mayúsculas, desactiva. 1, t y yes no
lo hacen, y un valor vacío cuenta como no definido.
Cómo saber si está activo
Sección titulada «Cómo saber si está activo»Lo indica la ficha enumerante, en la ruta .well-known. La ficha SEP-2127 de /server-card solo declara identidad y datos de conexión, y no lleva bloques de capacidades.
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"}El bloque no aparece cuando la telemetría está apagada, en vez de aparecer
diciendo false: no deberías tener que interpretar una negación para saber que
no se está registrando nada. La dirección del colector no se publica ahí, a
propósito, porque nombra tu infraestructura y la tarjeta se sirve a quien la
pida.
Cuando algo va mal
Sección titulada «Cuando algo va mal»Un fallo de telemetría nunca afecta a una petición. Un colector caído, que rechaza conexiones o que rechaza tu credencial produce líneas de log en stderr y no cambia nada más: el mismo estado, el mismo cuerpo, la misma latencia. La especificación deja esta decisión abierta (“la API o el SDK PUEDEN fallar rápido … pero NO DEBEN hacer que la aplicación falle más tarde en ejecución”), y aquí la decisión es que un servidor que puede hablar con GitLab siga haciéndolo cuando no puede hablar con un colector.
GITLAB_MCP_LOG_LEVEL=debug gitlab-mcp-server --telemetry 2>telemetry.logLos fallos de exportación, los pares de cabecera mal formados, las duraciones que no parsean y los avisos de protocolo llegan ahí como registros estructurados.
Estándares que sigue
Sección titulada «Estándares que sigue»| Área | Fuente |
|---|---|
| Variables de configuración | OTLP Exporter Configuration |
| Forma de spans y métricas | Convenciones semánticas de MCP |
| Niveles de requisito de atributos | Attribute Requirement Level |
| Registro de errores | Recording Errors |
| Atributos de identidad | User attributes |
La convención semántica de MCP está en estado Development, que su propia
tabla de madurez describe como “NO DEBERÍA usarse en producción” y “PUEDE
retirarse sin aviso previo”. Se adopta igualmente, por un motivo: afecta a los
operadores y no a los clientes MCP, así que un cambio de convención cuesta
editar un cuadro de mando y no cuesta nada a quien consume este servidor. Vive
en un repositorio aparte y no en opentelemetry.io, cuyo
/docs/specs/semconv/mcp/ devuelve 404.