Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/dd-trace/src/llmobs/experiments/experiment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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
}

Expand Down
11 changes: 10 additions & 1 deletion packages/dd-trace/src/llmobs/experiments/index.js
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand Down
55 changes: 55 additions & 0 deletions packages/dd-trace/src/llmobs/git-metadata.js
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions packages/dd-trace/src/llmobs/span_processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Comment on lines +340 to +341

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update LLMObs tests for injected git tags

With the default DD_TRACE_GIT_METADATA_ENABLED=true, real useLlmObs() tests run from this git checkout will now add git.commit.sha and git.repository_url to every formatted LLMObs span, but packages/dd-trace/test/llmobs/util.js still exact-matches the tag array length and expected entries. Existing SDK/plugin tests that call assertLlmObsSpanEvent without git tags will fail unless the helper accounts for these auto tags or the tests disable git metadata.

Useful? React with 👍 / 👎.


const errType = span.context().getTag(ERROR_TYPE) || error?.name
if (errType) tags.error_type = errType

Expand Down
23 changes: 12 additions & 11 deletions packages/dd-trace/src/plugins/util/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand All @@ -594,6 +594,7 @@ function fetchHeadCommitSha (headSha) {

module.exports = {
getGitMetadata,
getCommitSHA,
getLatestCommits,
getRepositoryUrl,
generatePackFilesForCommits,
Expand Down
41 changes: 41 additions & 0 deletions packages/dd-trace/test/llmobs/experiments/experiment.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
35 changes: 35 additions & 0 deletions packages/dd-trace/test/llmobs/experiments/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 } })
Expand Down
Loading
Loading