Skip to content

Commit

Permalink
Merge pull request #1369 from DataDog/rodrigo.roca/refactor-trace-com…
Browse files Browse the repository at this point in the history
…mand

[ci-visibility] Trace command: add support for Github, Gitlab, Azure, AWS and Buildkite
  • Loading branch information
rodrigo-roca authored Jul 24, 2024
2 parents 7910f8c + fe19614 commit 7fc6cdc
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 187 deletions.
8 changes: 6 additions & 2 deletions src/commands/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Trace a command with a custom span and report it to Datadog.
## Usage

```bash
datadog-ci trace [--no-fail] [--name <name>] -- <command>
datadog-ci trace [--name <name>] [--tags] [--measures] [--no-fail] [--dry-run] -- <command>
```

For example:
Expand All @@ -16,14 +16,18 @@ datadog-ci trace --name "Say Hello" -- echo "Hello World"

- The positional arguments are the command which will be launched and traced.
- `--name` (default: same as <command>) is a human-friendly name for the reported span.
- `--tags` is an array of key-value pairs with the format `key:value`. These tags are added to the custom span.
The resulting dictionary is merged with what is in the `DD_TAGS` environment variable. If a `key` appears both in `--tags` and `DD_TAGS`, the value in `DD_TAGS` takes precedence.
- `--measures` is an array of key-value pairs with the format `key:value`. These measures are added to the custom span.
The `value` must be a number.
- `--no-fail` (default: `false`) will prevent the trace command from failing even when not run in a supported CI Provider. In this case, the command will be launched and nothing will be reported to Datadog.
- `--dry-run` (default: `false`) runs the command without sending the custom span. All other checks are performed.

#### Environment variables

Additionally you might configure the `trace` command with environment variables:

- `DD_API_KEY` (**required**): API key used to authenticate the requests.
- `DD_ENV`: you may choose the environment you want your test results to appear in.
- `DD_TAGS`: set global tags applied to all spans. The format must be `key1:value1,key2:value2`.
- `DD_SITE`: choose your Datadog site, e.g. datadoghq.com or datadoghq.eu.

Expand Down
152 changes: 112 additions & 40 deletions src/commands/trace/__tests__/trace.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
/* eslint-disable no-null/no-null */
import {createCommand} from '../../../helpers/__tests__/fixtures'
import {PassThrough} from 'stream'

import {Cli} from 'clipanion/lib/advanced'

import {createMockContext} from '../../../helpers/__tests__/fixtures'

import {TraceCommand} from '../trace'

describe('trace', () => {
const runCLI = async (extraArgs: string[], extraEnv?: Record<string, string>) => {
const cli = new Cli()
cli.register(TraceCommand)
const context = createMockContext() as any
process.env = {DD_API_KEY: 'PLACEHOLDER', ...extraEnv}
context.env = process.env
context.stderr = new PassThrough()
const code = await cli.run(['trace', '--dry-run', ...extraArgs, '--', 'echo'], context)

return {context, code}
}

describe('signalToNumber', () => {
test('should map null to undefined', () => {
const command = new TraceCommand()
Expand All @@ -15,56 +31,112 @@ describe('trace', () => {
})
})

describe('getCIEnvVars', () => {
test('should throw if no CI is detected', () => {
describe('execute', () => {
test('should fail if no CI is detected', async () => {
process.env = {}
const command = createCommand(TraceCommand)
expect(command['getCIEnvVars'].bind(command)).toThrow(
/Cannot detect any supported CI Provider\. This command only works if run as part of your CI\..*/
)
const {context, code} = await runCLI([])
expect(code).toBe(1)
expect(context.stdout.toString()).toContain('Unsupported CI provider "unknown"')
})

test('should correctly detect the circleci environment', () => {
process.env = {
test('should succeed if no CI is detected but --no-fail is set', async () => {
process.env = {}
const {code} = await runCLI(['--no-fail'])
expect(code).toBe(0)
})
test('should detect the circleci environment', async () => {
const {context, code} = await runCLI([], {
CIRCLECI: 'true',
CIRCLE_WORKFLOW_ID: 'test',
CIRCLE_BUILD_NUM: '10',
NON_CIRCLE_ENV: 'bar',
}
const command = new TraceCommand()
expect(command['getCIEnvVars']()).toEqual([
{
CIRCLE_WORKFLOW_ID: 'test',
},
'circleci',
])
})
expect(code).toBe(0)
const dryRunOutput = context.stdout.toString()
expect(dryRunOutput).toContain('\\"CIRCLE_WORKFLOW_ID\\":\\"test\\"')
expect(dryRunOutput).toContain('\\"CIRCLE_BUILD_NUM\\":\\"10\\"')
})

test('should correctly detect the jenkins environment', () => {
process.env = {
test('should detect the jenkins environment', async () => {
const {context, code} = await runCLI([], {
DD_CUSTOM_TRACE_ID: 'abc',
DD_CUSTOM_PARENT_ID: 'xyz',
JENKINS_HOME: '/root',
JENKINS_URL: 'http://jenkins',
NON_JENKINS_ENV: 'bar',
WORKSPACE: 'def',
}
const command = new TraceCommand()
expect(command['getCIEnvVars']()).toEqual([
{
DD_CUSTOM_TRACE_ID: 'abc',
WORKSPACE: 'def',
},
'jenkins',
])
})
expect(code).toBe(0)
const dryRunOutput = context.stdout.toString()
expect(dryRunOutput).toContain('\\"DD_CUSTOM_TRACE_ID\\":\\"abc\\"')
expect(dryRunOutput).toContain('\\"DD_CUSTOM_PARENT_ID\\":\\"xyz\\"')
})

test('should not detect the jenkins environment if it is not instrumented', () => {
process.env = {
// DD_CUSTOM_TRACE_ID not defined to simulate a non-instrumented instance
JENKINS_HOME: '/root',
NON_JENKINS_ENV: 'bar',
WORKSPACE: 'def',
}
const command = new TraceCommand()
expect(command['getCIEnvVars'].call({context: {stdout: {write: () => undefined}}})).toEqual([{}])
test('should detect the github environment', async () => {
const {context, code} = await runCLI([], {
GITHUB_ACTIONS: 'true',
GITHUB_SERVER_URL: 'http://github',
GITHUB_REPOSITORY: 'test/test',
GITHUB_RUN_ID: '10',
GITHUB_RUN_ATTEMPT: '1',
GITHUB_JOB: 'jobname',
DD_GITHUB_JOB_NAME: 'custom_jobname',
})
expect(code).toBe(0)
const dryRunOutput = context.stdout.toString()
expect(dryRunOutput).toContain('\\"GITHUB_SERVER_URL\\":\\"http://github\\"')
expect(dryRunOutput).toContain('\\"GITHUB_REPOSITORY\\":\\"test/test\\"')
expect(dryRunOutput).toContain('\\"GITHUB_RUN_ID\\":\\"10\\"')
expect(dryRunOutput).toContain('\\"GITHUB_RUN_ATTEMPT\\":\\"1\\"')
expect(dryRunOutput).toContain('\\"DD_GITHUB_JOB_NAME\\":\\"custom_jobname\\"')
expect(dryRunOutput).toContain('"ci.job.name":"jobname"')
})
test('should detect the gitlab environment', async () => {
const {context, code} = await runCLI([], {
GITLAB_CI: 'true',
CI_PROJECT_URL: 'http://gitlab',
CI_PIPELINE_ID: '10',
CI_JOB_ID: '50',
})
expect(code).toBe(0)
const dryRunOutput = context.stdout.toString()
expect(dryRunOutput).toContain('\\"CI_PROJECT_URL\\":\\"http://gitlab\\"')
expect(dryRunOutput).toContain('\\"CI_PIPELINE_ID\\":\\"10\\"')
expect(dryRunOutput).toContain('\\"CI_JOB_ID\\":\\"50\\"')
})
test('should detect the azure environment', async () => {
const {context, code} = await runCLI([], {
TF_BUILD: 'true',
SYSTEM_TEAMPROJECTID: 'test',
BUILD_BUILDID: '10',
SYSTEM_JOBID: '3acfg',
})
expect(code).toBe(0)
const dryRunOutput = context.stdout.toString()
expect(dryRunOutput).toContain('\\"SYSTEM_TEAMPROJECTID\\":\\"test\\"')
expect(dryRunOutput).toContain('\\"BUILD_BUILDID\\":\\"10\\"')
expect(dryRunOutput).toContain('\\"SYSTEM_JOBID\\":\\"3acfg\\"')
})
test('should detect the aws codepipeline environment', async () => {
const {context, code} = await runCLI([], {
CODEBUILD_INITIATOR: 'codepipeline-abc',
DD_PIPELINE_EXECUTION_ID: 'def-234',
CODEBUILD_BUILD_ARN: 'arn:aws:codebuild:us-west-2:123456789012:build/MyProjectName:6a8f0d8a',
})
expect(code).toBe(0)
const dryRunOutput = context.stdout.toString()
expect(dryRunOutput).toContain('\\"DD_PIPELINE_EXECUTION_ID\\":\\"def-234\\"')
expect(dryRunOutput).toContain(
'\\"CODEBUILD_BUILD_ARN\\":\\"arn:aws:codebuild:us-west-2:123456789012:build/MyProjectName:6a8f0d8a\\"'
)
})
test('should detect the buildkite environment', async () => {
const {context, code} = await runCLI([], {
BUILDKITE: 'true',
BUILDKITE_BUILD_ID: 'abc',
BUILDKITE_JOB_ID: 'def',
})
expect(code).toBe(0)
const dryRunOutput = context.stdout.toString()
expect(dryRunOutput).toContain('\\"BUILDKITE_BUILD_ID\\":\\"abc\\"')
expect(dryRunOutput).toContain('\\"BUILDKITE_JOB_ID\\":\\"def\\"')
})
})
})
21 changes: 5 additions & 16 deletions src/commands/trace/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type {AxiosPromise, AxiosRequestConfig, AxiosResponse} from 'axios'

import {getGitMetadata} from '../../helpers/git/format-git-span-data'
import {getUserGitSpanTags} from '../../helpers/user-provided-git'
import {getRequestBuilder} from '../../helpers/utils'

import {Payload} from './interfaces'
Expand All @@ -11,27 +9,18 @@ import {Payload} from './interfaces'
const maxBodyLength = Infinity

export const reportCustomSpan = (request: (args: AxiosRequestConfig) => AxiosPromise<AxiosResponse>) => async (
customSpan: Payload,
provider: string
customSpan: Payload
) => {
const gitSpanTags = await getGitMetadata()
const userGitSpanTags = getUserGitSpanTags()

return request({
data: {
...customSpan,
tags: {
...gitSpanTags,
...userGitSpanTags,
...customSpan.tags,
data: {
type: 'ci_app_custom_span',
attributes: customSpan,
},
},
headers: {
'X-Datadog-CI-Custom-Event': provider,
},
maxBodyLength,
method: 'POST',
url: 'api/v2/webhook',
url: '/api/intake/ci/custom_spans',
})
}

Expand Down
30 changes: 16 additions & 14 deletions src/commands/trace/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import type {AxiosPromise, AxiosResponse} from 'axios'

export const CIRCLECI = 'circleci'
export const JENKINS = 'jenkins'
import {CI_ENGINES} from '../../helpers/ci'

export const SUPPORTED_PROVIDERS = [CIRCLECI, JENKINS] as const
export const SUPPORTED_PROVIDERS = [
CI_ENGINES.GITHUB,
CI_ENGINES.GITLAB,
CI_ENGINES.JENKINS,
CI_ENGINES.CIRCLECI,
CI_ENGINES.AWSCODEPIPELINE,
CI_ENGINES.AZURE,
CI_ENGINES.BUILDKITE,
] as const
export type Provider = typeof SUPPORTED_PROVIDERS[number]

export interface Payload {
ci_provider: string
span_id: string
command: string
custom: {
id: string
parent_id?: string
}
// Data is a map of CI-provider-specific environment variables
data: Record<string, string>
name: string
start_time: string
end_time: string
error_message: string
exit_code: number
is_error: boolean
measures: Partial<Record<string, number>>
name: string
start_time: string
tags: Partial<Record<string, string>>
measures: Partial<Record<string, number>>
}

export interface APIHelper {
reportCustomSpan(customSpan: Payload, provider: Provider): AxiosPromise<AxiosResponse>
reportCustomSpan(customSpan: Payload): AxiosPromise<AxiosResponse>
}
Loading

0 comments on commit 7fc6cdc

Please sign in to comment.