diff --git a/packages/dd-trace/src/llmobs/experiments/experiment.js b/packages/dd-trace/src/llmobs/experiments/experiment.js index d415f00a8f..a30b66b32e 100644 --- a/packages/dd-trace/src/llmobs/experiments/experiment.js +++ b/packages/dd-trace/src/llmobs/experiments/experiment.js @@ -102,7 +102,7 @@ class Experiment { #tags #experimentId - constructor (client, options = {}) { + constructor (client, options = {}, gitTags = {}) { if (!options.name) throw new Error('Experiment name is required') if (!options.dataset) throw new Error('Experiment dataset is required') if (typeof options.task !== 'function') throw new Error('Experiment task is required') @@ -114,7 +114,8 @@ class Experiment { this.#task = options.task this.#evaluators = new Map(Object.entries(options.evaluators ?? {})) this.#config = { ...options.config } - this.#tags = { ...options.tags } + // Git tags are defaults; user-supplied tags with the same key win. + this.#tags = { ...gitTags, ...options.tags } this.#experimentId = null } diff --git a/packages/dd-trace/src/llmobs/experiments/index.js b/packages/dd-trace/src/llmobs/experiments/index.js index ca9cd2f05c..4e7c1d5fb4 100644 --- a/packages/dd-trace/src/llmobs/experiments/index.js +++ b/packages/dd-trace/src/llmobs/experiments/index.js @@ -1,6 +1,8 @@ 'use strict' const log = require('../../log') +const { GIT_COMMIT_SHA, GIT_REPOSITORY_URL } = require('../../plugins/util/tags') +const resolveLLMObsGitMetadata = require('../git-metadata') const { ExperimentsClient, API_BASE_PATH } = require('./client') const { Dataset, DatasetRecord } = require('./dataset') const { Experiment } = require('./experiment') @@ -27,6 +29,7 @@ async function retryWithBackoff (attempt, { maxTotalMs = 30_000, baseDelayMs = 2 class Experiments { #client #projectName + #gitTags constructor (config) { this.#projectName = config.llmobs?.mlApp || config.service @@ -36,6 +39,12 @@ class Experiments { site: config.site, projectName: this.#projectName, }) + + // Resolved once for all experiments; applied as defaults that user tags override. + const { commitSHA, repositoryUrl } = resolveLLMObsGitMetadata(config) + this.#gitTags = {} + if (commitSHA) this.#gitTags[GIT_COMMIT_SHA] = commitSHA + if (repositoryUrl) this.#gitTags[GIT_REPOSITORY_URL] = repositoryUrl } // Create a local dataset buffer. Pushed remotely on first experiment run. @@ -124,7 +133,7 @@ class Experiments { // Build an experiment: { name, dataset, task, evaluators, description?, config?, tags? }. experiment (options) { - return new Experiment(this.#client, options) + return new Experiment(this.#client, options, this.#gitTags) } } diff --git a/packages/dd-trace/src/llmobs/git-metadata.js b/packages/dd-trace/src/llmobs/git-metadata.js new file mode 100644 index 0000000000..cf54812863 --- /dev/null +++ b/packages/dd-trace/src/llmobs/git-metadata.js @@ -0,0 +1,55 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') + +const getGitMetadata = require('../git_metadata') +const { getCommitSHA, getRepositoryUrl, isGitAvailable } = require('../plugins/util/git') +const { filterSensitiveInfoFromRepository } = require('../plugins/util/url') + +/** + * @typedef {{ commitSHA: string | undefined, repositoryUrl: string | undefined }} GitMetadata + * Cache enabled and disabled results independently: a disabled first call must not + * short-circuit a later enabled initialization in the same process (mirrors git_metadata.js). + * @type {{ enabled?: GitMetadata, disabled?: GitMetadata }} + */ +const cache = {} + +/** + * Resolve the git commit sha and repository url to tag LLMObs spans and + * experiments with. Widens coverage beyond the APM file/env resolver: in a + * typical local dev checkout the `DD_GIT_*` env vars and `git.properties` file + * aren't present, so we fall back to the `git` CLI for whatever the file/env + * reads missed. Both sources run at most once and the result is cached for the + * process lifetime, so the CLI subprocess never touches a hot path. Honors + * `DD_TRACE_GIT_METADATA_ENABLED`. + * + * @param {import('../config/config-types').ConfigProperties} config + * @returns {GitMetadata} + */ +function resolveLLMObsGitMetadata (config) { + if (!config.DD_TRACE_GIT_METADATA_ENABLED) { + cache.disabled ??= { commitSHA: undefined, repositoryUrl: undefined } + return cache.disabled + } + if (cache.enabled) return cache.enabled + + let { commitSHA, repositoryUrl } = getGitMetadata(config) + + // Only spawn the CLI when the file/env reads left something missing and we are + // inside a git checkout with git installed. Gating on the .git folder keeps + // no-repo production images (git present, no checkout) from spawning + // `git rev-parse` and emitting per-startup "not a git repository" error logs. + if (!commitSHA || !repositoryUrl) { + const gitFolder = config.DD_GIT_FOLDER_PATH ?? path.join(process.cwd(), '.git') + if (fs.existsSync(gitFolder) && isGitAvailable()) { + commitSHA ||= getCommitSHA() || undefined + repositoryUrl ||= filterSensitiveInfoFromRepository(getRepositoryUrl()) || undefined + } + } + + cache.enabled = { commitSHA, repositoryUrl } + return cache.enabled +} + +module.exports = resolveLLMObsGitMetadata diff --git a/packages/dd-trace/src/llmobs/span_processor.js b/packages/dd-trace/src/llmobs/span_processor.js index a41a629928..32fb136ff0 100644 --- a/packages/dd-trace/src/llmobs/span_processor.js +++ b/packages/dd-trace/src/llmobs/span_processor.js @@ -9,6 +9,7 @@ const { ERROR_TYPE, ERROR_STACK, } = require('../constants') +const { GIT_COMMIT_SHA, GIT_REPOSITORY_URL } = require('../plugins/util/tags') const { SPAN_KIND, MODEL_NAME, @@ -39,6 +40,7 @@ const { const { UNSERIALIZABLE_VALUE_TEXT } = require('./constants/text') const telemetry = require('./telemetry') const LLMObsTagger = require('./tagger') +const resolveLLMObsGitMetadata = require('./git-metadata') class LLMObservabilitySpan { /** @@ -69,8 +71,19 @@ class LLMObsSpanProcessor { /** @type {import('./writers/spans')} */ #writer + /** @type {string | undefined} */ + #gitCommitSHA + + /** @type {string | undefined} */ + #gitRepositoryUrl + constructor (config) { this.#config = config + + // Resolved once at LLMObs enable so the git-CLI fallback never runs per span. + const { commitSHA, repositoryUrl } = resolveLLMObsGitMetadata(config) + this.#gitCommitSHA = commitSHA + this.#gitRepositoryUrl = repositoryUrl } setUserSpanProcessor (userSpanProcessor) { @@ -322,6 +335,11 @@ class LLMObsSpanProcessor { language: 'javascript', } + // Git tags sit below the user tags spread below, so user-supplied + // `git.*` tags win on conflict. + if (this.#gitCommitSHA) tags[GIT_COMMIT_SHA] = this.#gitCommitSHA + if (this.#gitRepositoryUrl) tags[GIT_REPOSITORY_URL] = this.#gitRepositoryUrl + const errType = span.context().getTag(ERROR_TYPE) || error?.name if (errType) tags.error_type = errType diff --git a/packages/dd-trace/src/plugins/util/git.js b/packages/dd-trace/src/plugins/util/git.js index 3e1eb8ef05..ee9eb087d3 100644 --- a/packages/dd-trace/src/plugins/util/git.js +++ b/packages/dd-trace/src/plugins/util/git.js @@ -206,6 +206,16 @@ function getRepositoryUrl () { ) } +function getCommitSHA () { + return sanitizedExec( + 'git', + ['rev-parse', 'HEAD'], + { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_commit_sha' } }, + { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_commit_sha' } }, + { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_commit_sha' } } + ) +} + function getLatestCommits () { incrementCountMetric(TELEMETRY_GIT_COMMAND, { command: 'get_local_commits' }) const startTime = Date.now() @@ -559,17 +569,7 @@ function getGitMetadata (ciMetadata) { } function getGitInformationDiscrepancy () { - const gitRepositoryUrl = getRepositoryUrl() - - const gitCommitSHA = sanitizedExec( - 'git', - ['rev-parse', 'HEAD'], - { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_commit_sha' } }, - { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_commit_sha' } }, - { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_commit_sha' } } - ) - - return { gitRepositoryUrl, gitCommitSHA } + return { gitRepositoryUrl: getRepositoryUrl(), gitCommitSHA: getCommitSHA() } } function fetchHeadCommitSha (headSha) { @@ -594,6 +594,7 @@ function fetchHeadCommitSha (headSha) { module.exports = { getGitMetadata, + getCommitSHA, getLatestCommits, getRepositoryUrl, generatePackFilesForCommits, diff --git a/packages/dd-trace/test/llmobs/experiments/experiment.spec.js b/packages/dd-trace/test/llmobs/experiments/experiment.spec.js index 5ce9adb2d3..fd9c896480 100644 --- a/packages/dd-trace/test/llmobs/experiments/experiment.spec.js +++ b/packages/dd-trace/test/llmobs/experiments/experiment.spec.js @@ -168,6 +168,47 @@ describe('LLMObs Experiments — dataset + experiment run', () => { assert.deepEqual(span.meta.metadata, { row: 0 }) }) + it('applies git metadata as default tags on both spans and metrics', async () => { + const dataset = new Dataset(client, 'demo').addRecord({ q: 'apple' }, 'true') + await new Experiment(client, { + name: 'exp-demo', + dataset, + task: (i) => ({ answer: i.q }), + evaluators: { ok: () => true }, + }, { + 'git.commit.sha': 'abc123', + 'git.repository_url': 'https://github.com/example/repo', + }).run() + + const body = eventsBody() + const span = body.data.attributes.spans[0] + assert.ok(span.tags.includes('git.commit.sha:abc123')) + assert.ok(span.tags.includes('git.repository_url:https://github.com/example/repo')) + + const metric = body.data.attributes.metrics[0] + assert.ok(metric.tags.includes('git.commit.sha:abc123')) + assert.ok(metric.tags.includes('git.repository_url:https://github.com/example/repo')) + }) + + it('lets user-supplied tags override git metadata defaults', async () => { + const dataset = new Dataset(client, 'demo').addRecord({ q: 'apple' }, 'true') + await new Experiment(client, { + name: 'exp-demo', + dataset, + task: (i) => ({ answer: i.q }), + evaluators: { ok: () => true }, + tags: { 'git.commit.sha': 'user-override' }, + }, { + 'git.commit.sha': 'abc123', + 'git.repository_url': 'https://github.com/example/repo', + }).run() + + const span = eventsBody().data.attributes.spans[0] + assert.ok(span.tags.includes('git.commit.sha:user-override')) + assert.ok(!span.tags.includes('git.commit.sha:abc123')) + assert.ok(span.tags.includes('git.repository_url:https://github.com/example/repo')) + }) + it('infers metric type from the evaluator return value', async () => { const dataset = new Dataset(client, 'demo').addRecord('x') await new Experiment(client, { diff --git a/packages/dd-trace/test/llmobs/experiments/index.spec.js b/packages/dd-trace/test/llmobs/experiments/index.spec.js index ed789551f6..a942e3b9b4 100644 --- a/packages/dd-trace/test/llmobs/experiments/index.spec.js +++ b/packages/dd-trace/test/llmobs/experiments/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { afterEach, beforeEach, describe, it } = require('mocha') +const proxyquire = require('proxyquire') const sinon = require('sinon') const { createExperiments } = require('../../../src/llmobs/experiments') @@ -70,6 +71,40 @@ describe('LLMObs Experiments facade', () => { }) }) + describe('git metadata wiring', () => { + it('resolves git metadata once and forwards it as default tags to experiments', async () => { + const resolveLLMObsGitMetadata = sinon.stub().returns({ + commitSHA: 'abc123', + repositoryUrl: 'https://github.com/example/repo', + }) + const { createExperiments: create } = proxyquire('../../../src/llmobs/experiments', { + '../git-metadata': Object.assign(resolveLLMObsGitMetadata, { '@noCallThru': true }), + }) + + global.fetch.callsFake(async (url, opts) => { + const u = new URL(url) + const body = opts.body ? JSON.parse(opts.body) : undefined + let payload = {} + if (u.pathname.endsWith('/projects')) payload = { data: { id: 'proj' } } + else if (u.pathname.endsWith('/datasets/ds/records')) { + payload = { records: body.data.attributes.records.map((_, i) => ({ id: `rec-${i}` })) } + } else if (u.pathname.endsWith('/datasets')) payload = { data: { id: 'ds' } } + else if (u.pathname.endsWith('/experiments')) payload = { data: { id: 'exp' } } + return { ok: true, status: 200, text: sinon.stub().resolves(JSON.stringify(payload)) } + }) + + const exp = create(enabledConfig()) + const dataset = exp.createDataset('d').addRecord({ q: 'a' }, 'true') + await exp.experiment({ name: 'n', dataset, task: (i) => i, evaluators: { ok: () => true } }).run() + + sinon.assert.calledOnce(resolveLLMObsGitMetadata) + const eventsCall = global.fetch.getCalls().find(c => new URL(c.args[0]).pathname.endsWith('/exp/events')) + const span = JSON.parse(eventsCall.args[1].body).data.attributes.spans[0] + assert.ok(span.tags.includes('git.commit.sha:abc123')) + assert.ok(span.tags.includes('git.repository_url:https://github.com/example/repo')) + }) + }) + describe('no-op (disabled / missing keys)', () => { it('throws on every operation with a clear message', async () => { const exp = createExperiments({ llmobs: { DD_LLMOBS_ENABLED: false } }) diff --git a/packages/dd-trace/test/llmobs/git-metadata.spec.js b/packages/dd-trace/test/llmobs/git-metadata.spec.js new file mode 100644 index 0000000000..1946d00cf4 --- /dev/null +++ b/packages/dd-trace/test/llmobs/git-metadata.spec.js @@ -0,0 +1,181 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { beforeEach, describe, it } = require('mocha') +const proxyquire = require('proxyquire') +const sinon = require('sinon') + +require('./../setup/core') + +// Load a fresh resolver (module-level cache reset) with the file/env resolver +// and git-CLI helpers stubbed. +function load ({ fromFile = {}, commitSHA = '', repositoryUrl = '', gitAvailable = true } = {}) { + const getGitMetadata = sinon.stub().returns({ + commitSHA: fromFile.commitSHA, + repositoryUrl: fromFile.repositoryUrl, + }) + const getCommitSHA = sinon.stub().returns(commitSHA) + const getRepositoryUrl = sinon.stub().returns(repositoryUrl) + const isGitAvailable = sinon.stub().returns(gitAvailable) + + const resolveLLMObsGitMetadata = proxyquire.noPreserveCache()('../../src/llmobs/git-metadata', { + '../git_metadata': Object.assign(getGitMetadata, { '@noCallThru': true }), + '../plugins/util/git': { getCommitSHA, getRepositoryUrl, isGitAvailable, '@noCallThru': true }, + }) + + return { resolveLLMObsGitMetadata, getGitMetadata, getCommitSHA, getRepositoryUrl, isGitAvailable } +} + +describe('llmobs git metadata resolver', () => { + let config + + beforeEach(() => { + // __dirname always exists, so the CLI fallback's git-folder gate passes without + // depending on the working directory the tests happen to run from. + config = { DD_TRACE_GIT_METADATA_ENABLED: true, DD_GIT_FOLDER_PATH: __dirname } + }) + + it('returns file/env metadata without consulting the CLI when both are present', () => { + const { resolveLLMObsGitMetadata, getCommitSHA, getRepositoryUrl, isGitAvailable } = load({ + fromFile: { commitSHA: 'envsha', repositoryUrl: 'https://github.com/from-env' }, + }) + + assert.deepStrictEqual(resolveLLMObsGitMetadata(config), { + commitSHA: 'envsha', + repositoryUrl: 'https://github.com/from-env', + }) + sinon.assert.notCalled(isGitAvailable) + sinon.assert.notCalled(getCommitSHA) + sinon.assert.notCalled(getRepositoryUrl) + }) + + it('falls back to the git CLI for both values when file/env yields nothing', () => { + const { resolveLLMObsGitMetadata } = load({ + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-shell', + }) + + assert.deepStrictEqual(resolveLLMObsGitMetadata(config), { + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-shell', + }) + }) + + it('fills only the missing commit sha from the CLI', () => { + const { resolveLLMObsGitMetadata, getCommitSHA, getRepositoryUrl } = load({ + fromFile: { repositoryUrl: 'https://github.com/from-env' }, + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-shell', + }) + + assert.deepStrictEqual(resolveLLMObsGitMetadata(config), { + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-env', + }) + sinon.assert.calledOnce(getCommitSHA) + sinon.assert.notCalled(getRepositoryUrl) + }) + + it('fills only the missing repository url from the CLI', () => { + const { resolveLLMObsGitMetadata, getCommitSHA, getRepositoryUrl } = load({ + fromFile: { commitSHA: 'envsha' }, + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-shell', + }) + + assert.deepStrictEqual(resolveLLMObsGitMetadata(config), { + commitSHA: 'envsha', + repositoryUrl: 'https://github.com/from-shell', + }) + sinon.assert.notCalled(getCommitSHA) + sinon.assert.calledOnce(getRepositoryUrl) + }) + + it('leaves values undefined when the CLI resolves to empty strings', () => { + const { resolveLLMObsGitMetadata } = load({ commitSHA: '', repositoryUrl: '' }) + + assert.deepStrictEqual(resolveLLMObsGitMetadata(config), { + commitSHA: undefined, + repositoryUrl: undefined, + }) + }) + + it('strips credentials from a CLI-resolved repository url', () => { + const { resolveLLMObsGitMetadata } = load({ + commitSHA: 'shellsha', + repositoryUrl: 'https://x-token:secret@github.com/example/repo.git', + }) + + const { repositoryUrl } = resolveLLMObsGitMetadata(config) + assert.ok(!repositoryUrl.includes('secret')) + assert.strictEqual(repositoryUrl, 'https://github.com/example/repo.git') + }) + + it('skips the CLI when not inside a git checkout', () => { + const { resolveLLMObsGitMetadata, getCommitSHA, getRepositoryUrl, isGitAvailable } = load({ + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-shell', + }) + + const result = resolveLLMObsGitMetadata({ + DD_TRACE_GIT_METADATA_ENABLED: true, + DD_GIT_FOLDER_PATH: '/nonexistent/.git', + }) + assert.deepStrictEqual(result, { commitSHA: undefined, repositoryUrl: undefined }) + sinon.assert.notCalled(isGitAvailable) + sinon.assert.notCalled(getCommitSHA) + sinon.assert.notCalled(getRepositoryUrl) + }) + + it('returns undefined values and skips the CLI when git is unavailable', () => { + const { resolveLLMObsGitMetadata, getCommitSHA, getRepositoryUrl } = load({ gitAvailable: false }) + + // No DD_GIT_FOLDER_PATH here, so the folder gate falls back to `${cwd}/.git` + // (present when the suite runs from the repo root). + assert.deepStrictEqual(resolveLLMObsGitMetadata({ DD_TRACE_GIT_METADATA_ENABLED: true }), { + commitSHA: undefined, + repositoryUrl: undefined, + }) + sinon.assert.notCalled(getCommitSHA) + sinon.assert.notCalled(getRepositoryUrl) + }) + + it('returns empty metadata and touches nothing when DD_TRACE_GIT_METADATA_ENABLED is false', () => { + const { resolveLLMObsGitMetadata, getGitMetadata, getCommitSHA, getRepositoryUrl, isGitAvailable } = load({ + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-shell', + }) + + assert.deepStrictEqual(resolveLLMObsGitMetadata({ DD_TRACE_GIT_METADATA_ENABLED: false }), { + commitSHA: undefined, + repositoryUrl: undefined, + }) + sinon.assert.notCalled(getGitMetadata) + sinon.assert.notCalled(isGitAvailable) + sinon.assert.notCalled(getCommitSHA) + sinon.assert.notCalled(getRepositoryUrl) + }) + + it('caches enabled and disabled results independently', () => { + const { resolveLLMObsGitMetadata, getGitMetadata, getCommitSHA } = load({ + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-shell', + }) + + // A disabled call first must not poison a later enabled call. + const disabled = resolveLLMObsGitMetadata({ DD_TRACE_GIT_METADATA_ENABLED: false }) + assert.deepStrictEqual(disabled, { commitSHA: undefined, repositoryUrl: undefined }) + + const enabled = resolveLLMObsGitMetadata(config) + assert.deepStrictEqual(enabled, { + commitSHA: 'shellsha', + repositoryUrl: 'https://github.com/from-shell', + }) + + // Both results are memoized: a repeat call returns the same object without re-resolving. + assert.strictEqual(resolveLLMObsGitMetadata(config), enabled) + sinon.assert.calledOnce(getGitMetadata) + sinon.assert.calledOnce(getCommitSHA) + }) +}) diff --git a/packages/dd-trace/test/llmobs/span_processor.spec.js b/packages/dd-trace/test/llmobs/span_processor.spec.js index b6892f5f20..441fd96d1f 100644 --- a/packages/dd-trace/test/llmobs/span_processor.spec.js +++ b/packages/dd-trace/test/llmobs/span_processor.spec.js @@ -27,6 +27,10 @@ describe('span processor', () => { LLMObsSpanProcessor = proxyquire('../../src/llmobs/span_processor', { '../../../../package.json': { version: 'x.y.z' }, '../log': log, + // Default to no git metadata so the exact-tag assertions stay deterministic + // regardless of the resolver's process-global cache. The git-metadata + // describe block below overrides this with its own stub. + './git-metadata': () => ({ commitSHA: undefined, repositoryUrl: undefined }), }) processor = new LLMObsSpanProcessor({ llmobs: { DD_LLMOBS_ENABLED: true } }) @@ -767,4 +771,69 @@ describe('span processor', () => { assert.strictEqual(apmTags['_dd.llmobs.submitted'], undefined) }) }) + + describe('git metadata tags', () => { + function makeSpan (mlObsTags) { + const span = { + _name: 'test', + _startTime: 0, + _duration: 1, + context () { + return { + _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, + toTraceId () { return '123' }, + toSpanId () { return '456' }, + } + }, + } + LLMObsTagger.tagMap.set(span, { + '_ml_obs.meta.span.kind': 'workflow', + '_ml_obs.meta.ml_app': 'myApp', + ...mlObsTags, + }) + return span + } + + function buildProcessor (gitMetadata) { + const GitAwareProcessor = proxyquire('../../src/llmobs/span_processor', { + '../../../../package.json': { version: 'x.y.z' }, + '../log': log, + './git-metadata': () => gitMetadata, + }) + const p = new GitAwareProcessor({ llmobs: { DD_LLMOBS_ENABLED: true } }) + p.setWriter(writer) + return p + } + + it('tags spans with the resolved git commit sha and repository url', () => { + const p = buildProcessor({ commitSHA: 'abc123', repositoryUrl: 'https://github.com/example/repo' }) + p.process(makeSpan()) + + const { tags } = writer.append.getCall(0).firstArg + assert.ok(tags.includes('git.commit.sha:abc123')) + assert.ok(tags.includes('git.repository_url:https://github.com/example/repo')) + }) + + it('omits git tags when metadata is unavailable', () => { + const p = buildProcessor({ commitSHA: undefined, repositoryUrl: undefined }) + p.process(makeSpan()) + + const { tags } = writer.append.getCall(0).firstArg + assert.ok(!tags.some(tag => tag.startsWith('git.commit.sha:'))) + assert.ok(!tags.some(tag => tag.startsWith('git.repository_url:'))) + }) + + it('lets user-supplied git tags win over the resolved values', () => { + const p = buildProcessor({ commitSHA: 'abc123', repositoryUrl: 'https://github.com/example/repo' }) + p.process(makeSpan({ '_ml_obs.tags': { 'git.commit.sha': 'user-override' } })) + + const { tags } = writer.append.getCall(0).firstArg + assert.ok(tags.includes('git.commit.sha:user-override')) + assert.ok(!tags.includes('git.commit.sha:abc123')) + assert.ok(tags.includes('git.repository_url:https://github.com/example/repo')) + }) + }) }) diff --git a/packages/dd-trace/test/llmobs/util.js b/packages/dd-trace/test/llmobs/util.js index 6aad934150..f1500e78ac 100644 --- a/packages/dd-trace/test/llmobs/util.js +++ b/packages/dd-trace/test/llmobs/util.js @@ -418,6 +418,11 @@ function useLlmObs ({ useEnv({ _DD_LLMOBS_FLUSH_INTERVAL: 0, + // Git metadata tags (git.commit.sha / git.repository_url) are derived from the + // checkout the tests run in, so their values are environment-dependent and can't + // be pinned in the exact-tag assertions. Disable them here; the git-tagging + // behavior is covered deterministically in span_processor.spec / git-metadata.spec. + DD_TRACE_GIT_METADATA_ENABLED: 'false', }) before(async () => {