Skip to content
Draft
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
174 changes: 172 additions & 2 deletions packages/base/src/helpers/__tests__/ci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import fs from 'fs'

import upath from 'upath'

import {getCIEnv, getCIMetadata, getCISpanTags, isInteractive} from '../ci'
import {
getCIEnv,
getCIMetadata,
getCISpanTags,
getGithubJobDisplayNameFromLogs,
githubWellKnownDiagnosticDirs,
isInteractive,
} from '../ci'
import {SpanTags} from '../interfaces'
import {
CI_ENV_VARS,
CI_JOB_ID,
CI_NODE_LABELS,
CI_NODE_NAME,
GIT_HEAD_SHA,
Expand Down Expand Up @@ -427,6 +433,170 @@ describe('isInteractive', () => {
})
})

describe('getGithubJobDisplayNameFromLogs', () => {
const mockedFs = fs as jest.Mocked<typeof fs>

afterEach(() => {
jest.resetAllMocks()
})

const getNotFoundFsError = (): Error => {
const error = new Error('not found')
Object.assign(error, {code: 'ENOENT'})

return error
}

const mockLogFileDirent = (logFileName: string): fs.Dirent => {
return {
name: logFileName,
isFile: () => true,
isDirectory: () => false,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
parentPath: '',
path: '',
}
}

const sampleLogContent = (jobDisplayName: string): string => `
[2025-09-15 10:14:00Z INFO Worker] Waiting to receive the job message from the channel.
[2025-09-15 10:14:00Z INFO ProcessChannel] Receiving message of length 22985, with hash 'abcdef'
[2025-09-15 10:14:00Z INFO Worker] Message received.
[2025-09-15 10:14:00Z INFO Worker] Job message:
{
"jobId": "95a4619c-e316-542f-8a21-74cd5a8ac9ca",
"jobDisplayName": "${jobDisplayName}",
"jobName": "__default"
}`

const sampleLogFileName = 'Worker_20251014-083000.log'
const sampleJobDisplayName = 'build-and-test'

const mockReaddirSync = (targetDir: fs.PathLike, logFileName: string) => {
fs.readdirSync = jest.fn().mockImplementation((pathToRead: fs.PathLike): fs.Dirent[] => {
if (pathToRead === targetDir) {
return [mockLogFileDirent(logFileName)]
}
throw getNotFoundFsError()
})
}

test('should find and return the job display name (SaaS)', () => {
const targetDir = githubWellKnownDiagnosticDirs[0] // SaaS directory
const logContent = sampleLogContent(sampleJobDisplayName)

mockReaddirSync(targetDir, sampleLogFileName)
jest.spyOn(fs, 'readFileSync').mockReturnValue(logContent)

const result = getGithubJobDisplayNameFromLogs()

expect(result).toBe(sampleJobDisplayName)
expect(mockedFs.readdirSync).toHaveBeenCalledWith(targetDir, {withFileTypes: true})
expect(mockedFs.readFileSync).toHaveBeenCalledWith(`${targetDir}/${sampleLogFileName}`, 'utf-8')
})

test('should find and return the job display name (self-hosted)', () => {
const targetDir = githubWellKnownDiagnosticDirs[1] // self-hosted directory
const logContent = sampleLogContent(sampleJobDisplayName)

mockReaddirSync(targetDir, sampleLogFileName)
jest.spyOn(fs, 'readFileSync').mockReturnValue(logContent)

const result = getGithubJobDisplayNameFromLogs()

expect(result).toBe(sampleJobDisplayName)
expect(mockedFs.readdirSync).toHaveBeenCalledWith(targetDir, {withFileTypes: true})
expect(mockedFs.readFileSync).toHaveBeenCalledWith(`${targetDir}/${sampleLogFileName}`, 'utf-8')
})

test('should check multiple log files until the display name is found', () => {
const logContent1 = 'no job display name here'
const logContent2 = 'nor here'
const logContent3 = sampleLogContent('my job name')

fs.readdirSync = jest.fn().mockImplementation((pathToRead: fs.PathLike): fs.Dirent[] => {
return [mockLogFileDirent('Worker_1.log'), mockLogFileDirent('Worker_2.log'), mockLogFileDirent('Worker_3.log')]
})
jest.spyOn(fs, 'readFileSync').mockReturnValue(logContent1)
jest.spyOn(fs, 'readFileSync').mockReturnValue(logContent2)
jest.spyOn(fs, 'readFileSync').mockReturnValue(logContent3)

const result = getGithubJobDisplayNameFromLogs()

expect(result).toBe('my job name')
})

test('should throw an error if no diagnostic log directories are found', () => {
fs.readdirSync = jest.fn().mockImplementation((pathToRead: fs.PathLike): fs.Dirent[] => {
throw getNotFoundFsError()
})

expect(() => {
getGithubJobDisplayNameFromLogs()
}).toThrow('could not find Github diagnostic log files')
})

test('should throw an error if no worker log files are found in any directory', () => {
fs.readdirSync = jest.fn().mockImplementation((pathToRead: fs.PathLike): fs.Dirent[] => {
return [mockLogFileDirent('random_file_1'), mockLogFileDirent('random_file_2')]
})

expect(() => {
getGithubJobDisplayNameFromLogs()
}).toThrow('could not find Github diagnostic log files')
})

test('should throw an error if log files are found but none contain the display name', () => {
const targetDir = githubWellKnownDiagnosticDirs[0]
const logContent = 'This log does not have the job display name.'

mockReaddirSync(targetDir, sampleLogFileName)
jest.spyOn(fs, 'readFileSync').mockReturnValue(logContent)

expect(() => {
getGithubJobDisplayNameFromLogs()
}).toThrow('could not find jobDisplayName attribute in Github diagnostic logs')
})

test('should throw an error if reading a directory throws an unexpected error', () => {
const accessDeniedError = new Error('access denied')
Object.assign(accessDeniedError, {code: 'EACCES'})

fs.readdirSync = jest.fn().mockImplementation((pathToRead: fs.PathLike): fs.Dirent[] => {
throw accessDeniedError
})

expect(() => {
getGithubJobDisplayNameFromLogs()
}).toThrow(`error reading Github diagnostic log files: access denied`)
})

test('should re-throw for unexpected errors', () => {
const err = Error('some error')

fs.readdirSync = jest.fn().mockImplementation((pathToRead: fs.PathLike): fs.Dirent[] => {
throw err
})

expect(() => {
getGithubJobDisplayNameFromLogs()
}).toThrow(new Error('error reading Github diagnostic log files: some error'))

const stringErr = 'hello error'
fs.readdirSync = jest.fn().mockImplementation((pathToRead: fs.PathLike): fs.Dirent[] => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw stringErr
})
expect(() => {
getGithubJobDisplayNameFromLogs()
}).toThrow(stringErr)
})
})

const getTags = (): SpanTags => {
return {
...getCISpanTags(),
Expand Down
77 changes: 72 additions & 5 deletions packages/base/src/helpers/ci.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import fs from 'fs'

import upath from 'upath'

import {Metadata, SpanTag, SpanTags} from './interfaces'
import {
CI_ENV_VARS,
Expand Down Expand Up @@ -56,13 +60,18 @@ export const CI_ENGINES = {
TEAMCITY: 'teamcity',
}

export const PROVIDER_TO_DISPLAY_NAME = {
github: 'GitHub Actions',
}
export const envDDGithubJobName = 'DD_GITHUB_JOB_NAME'

// DD_GITHUB_JOB_NAME is an override that is required for adding custom tags and metrics
// to GHA jobs if the 'name' property is used. It's ok for it to be missing in case the name property is not used.
const envAllowedToBeMissing = ['DD_GITHUB_JOB_NAME']
const envAllowedToBeMissing = [envDDGithubJobName]

export const githubWellKnownDiagnosticDirs = [
'/home/runner/actions-runner/cached/_diag', // for SaaS
'/home/runner/actions-runner/_diag', // for self-hosted
]

const githubJobDisplayNameRegex = /"jobDisplayName":\s*"([^"]+)"/

// Receives a string with the form 'John Doe <[email protected]>'
// and returns { name: 'John Doe', email: '[email protected]' }
Expand Down Expand Up @@ -901,7 +910,7 @@ export const getCIEnv = (): {ciEnv: Record<string, string>; provider: string} =>
'GITHUB_RUN_ID',
'GITHUB_RUN_ATTEMPT',
'GITHUB_JOB',
'DD_GITHUB_JOB_NAME',
envDDGithubJobName,
]),
provider: 'github',
}
Expand Down Expand Up @@ -1020,3 +1029,61 @@ const filterEnv = (values: string[]): Record<string, string> => {
export const isInteractive = ({stream = process.stdout}: {stream?: NodeJS.WriteStream} = {}) => {
return Boolean(!('CI' in process.env) && process.env.TERM !== 'dumb' && stream && stream.isTTY)
}

export const shouldGetGithubJobDisplayName = (): boolean => {
return getCIProvider() !== CI_ENGINES.GITHUB && process.env.DD_GITHUB_JOB_NAME !== ''
}

/**
* Extracts the job display name from the GitHub Actions diagnostic log files.
*
* @returns The job display name, or an empty string if not found.
*/
export const getGithubJobDisplayNameFromLogs = (): string => {
let foundDiagDir = ''
let workerLogFiles: string[] = []

// 1. Iterate through well known directories to check for worker logs
for (const currentDir of githubWellKnownDiagnosticDirs) {
try {
const files = fs.readdirSync(currentDir, {withFileTypes: true})
const potentialLogs = files
.filter((file) => file.isFile() && file.name.startsWith('Worker_') && file.name.endsWith('.log'))
.map((file) => file.name)

if (potentialLogs.length > 0) {
foundDiagDir = currentDir
workerLogFiles = potentialLogs
break
}
} catch (error) {
// If the directory does not exist, just try the next one
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
continue
}
if (error instanceof Error) {
throw new Error(`error reading Github diagnostic log files: ${error.message}`)
} else {
throw error
}
}
}
if (workerLogFiles.length === 0 || foundDiagDir === '') {
throw new Error('could not find Github diagnostic log files')
}

// 2. Get the job display name via regex
for (const logFile of workerLogFiles) {
const filePath = upath.join(foundDiagDir, logFile)
const content = fs.readFileSync(filePath, 'utf-8')

const match = content.match(githubJobDisplayNameRegex)

if (match && match[1]) {
// match[1] is the captured group
return match[1]
}
}

throw Error('could not find jobDisplayName attribute in Github diagnostic logs')
}
23 changes: 22 additions & 1 deletion packages/datadog-ci/src/commands/measure/measure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type {AxiosError} from 'axios'

import {BaseCommand} from '@datadog/datadog-ci-base'
import {FIPS_ENV_VAR, FIPS_IGNORE_ERROR_ENV_VAR} from '@datadog/datadog-ci-base/constants'
import {getCIEnv} from '@datadog/datadog-ci-base/helpers/ci'
import {
envDDGithubJobName,
getCIEnv,
getGithubJobDisplayNameFromLogs,
shouldGetGithubJobDisplayName,
} from '@datadog/datadog-ci-base/helpers/ci'
import {toBoolean} from '@datadog/datadog-ci-base/helpers/env'
import {enableFips} from '@datadog/datadog-ci-base/helpers/fips'
import {retryRequest} from '@datadog/datadog-ci-base/helpers/retry'
Expand Down Expand Up @@ -97,9 +102,25 @@ export class MeasureCommand extends BaseCommand {
return 1
}

let githubDisplayName = ''
try {
if (shouldGetGithubJobDisplayName()) {
this.context.stdout.write('Inferring github job name\n')
githubDisplayName = getGithubJobDisplayNameFromLogs()
}
} catch (error) {
this.context.stdout.write(
`could not infer job display name, defaulting to env variables ${error instanceof Error ? error.message : ''}\n`
)
}

try {
const {provider, ciEnv} = getCIEnv()

if (githubDisplayName !== '' && !Object.values(ciEnv).includes(envDDGithubJobName)) {
ciEnv[envDDGithubJobName] = githubDisplayName
}

const exitStatus = await this.sendMeasures(ciEnv, this.level === 'pipeline' ? 0 : 1, provider, measures)
if (exitStatus !== 0 && this.noFail) {
this.context.stderr.write(
Expand Down
Loading