From 89bb0867b9f26fb8b290055035736046fc354d6d Mon Sep 17 00:00:00 2001 From: Yoann Moinet Date: Fri, 16 May 2025 15:56:05 +0200 Subject: [PATCH 1/3] Bootstrap CI Visibility plugin --- .github/CODEOWNERS | 3 + packages/plugins/ci-visibility/README.md | 21 + packages/plugins/ci-visibility/package.json | 30 + .../plugins/ci-visibility/src/constants.ts | 79 ++ .../src/helpers/buildSpansPlugin.ts | 99 +++ .../ci-visibility/src/helpers/ciSpanTags.ts | 763 ++++++++++++++++++ .../ci-visibility/src/helpers/customSpans.ts | 56 ++ .../src/helpers/parseTags.test.ts | 84 ++ .../ci-visibility/src/helpers/parseTags.ts | 54 ++ .../ci-visibility/src/helpers/sendSpans.ts | 92 +++ .../plugins/ci-visibility/src/index.test.ts | 69 ++ packages/plugins/ci-visibility/src/index.ts | 134 +++ packages/plugins/ci-visibility/src/types.ts | 100 +++ packages/plugins/ci-visibility/tsconfig.json | 10 + yarn.lock | 12 + 15 files changed, 1606 insertions(+) create mode 100644 packages/plugins/ci-visibility/README.md create mode 100644 packages/plugins/ci-visibility/package.json create mode 100644 packages/plugins/ci-visibility/src/constants.ts create mode 100644 packages/plugins/ci-visibility/src/helpers/buildSpansPlugin.ts create mode 100644 packages/plugins/ci-visibility/src/helpers/ciSpanTags.ts create mode 100644 packages/plugins/ci-visibility/src/helpers/customSpans.ts create mode 100644 packages/plugins/ci-visibility/src/helpers/parseTags.test.ts create mode 100644 packages/plugins/ci-visibility/src/helpers/parseTags.ts create mode 100644 packages/plugins/ci-visibility/src/helpers/sendSpans.ts create mode 100644 packages/plugins/ci-visibility/src/index.test.ts create mode 100644 packages/plugins/ci-visibility/src/index.ts create mode 100644 packages/plugins/ci-visibility/src/types.ts create mode 100644 packages/plugins/ci-visibility/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index efcbec882..f7af8ed81 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -32,3 +32,6 @@ packages/plugins/custom-hooks @yoannmoin # True End packages/plugins/true-end @yoannmoinet + +# Ci Visibility +packages/plugins/ci-visibility @yoannmoinet diff --git a/packages/plugins/ci-visibility/README.md b/packages/plugins/ci-visibility/README.md new file mode 100644 index 000000000..a93ceabba --- /dev/null +++ b/packages/plugins/ci-visibility/README.md @@ -0,0 +1,21 @@ +# Ci Visibility Plugin + +Interact with CI Visibility directly from your build system. + + + +## Table of content + + + + +- [Configuration](#configuration) + + +## Configuration + +```ts +ciVisibility?: { + disabled?: boolean; +} +``` \ No newline at end of file diff --git a/packages/plugins/ci-visibility/package.json b/packages/plugins/ci-visibility/package.json new file mode 100644 index 000000000..00a516e5a --- /dev/null +++ b/packages/plugins/ci-visibility/package.json @@ -0,0 +1,30 @@ +{ + "name": "@dd/ci-visibility-plugin", + "packageManager": "yarn@4.0.2", + "license": "MIT", + "private": true, + "author": "Datadog", + "description": "Interact with CI Visibility directly from your build system.", + "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/plugins/ci-visibility#readme", + "repository": { + "type": "git", + "url": "https://github.com/DataDog/build-plugins", + "directory": "packages/plugins/ci-visibility" + }, + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dd/core": "workspace:*", + "@dd/internal-build-report-plugin": "workspace:*", + "chalk": "2.3.1", + "p-queue": "6.6.2" + }, + "devDependencies": { + "typescript": "5.4.3" + } +} diff --git a/packages/plugins/ci-visibility/src/constants.ts b/packages/plugins/ci-visibility/src/constants.ts new file mode 100644 index 000000000..7076371bd --- /dev/null +++ b/packages/plugins/ci-visibility/src/constants.ts @@ -0,0 +1,79 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { PluginName } from '@dd/core/types'; + +export const CONFIG_KEY = 'ciVisibility' as const; +export const PLUGIN_NAME: PluginName = 'datadog-ci-visibility-plugin' as const; + +export const CI_ENGINES = { + APPVEYOR: 'appveyor', + AWSCODEPIPELINE: 'awscodepipeline', + AZURE: 'azurepipelines', + BITBUCKET: 'bitbucket', + BITRISE: 'bitrise', + BUDDY: 'buddy', + BUILDKITE: 'buildkite', + CIRCLECI: 'circleci', + CODEFRESH: 'codefresh', + GITHUB: 'github', + GITLAB: 'gitlab', + JENKINS: 'jenkins', + TRAVIS: 'travisci', + TEAMCITY: 'teamcity', + UNKNOWN: 'unknown', +}; + +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; + +// Tags +// For the CI provider. +export const CI_PIPELINE_URL = 'ci.pipeline.url'; +export const CI_PROVIDER_NAME = 'ci.provider.name'; +export const CI_PIPELINE_ID = 'ci.pipeline.id'; +export const CI_PIPELINE_NAME = 'ci.pipeline.name'; +export const CI_PIPELINE_NUMBER = 'ci.pipeline.number'; +export const CI_WORKSPACE_PATH = 'ci.workspace_path'; +export const GIT_REPOSITORY_URL = 'git.repository_url'; +export const CI_JOB_URL = 'ci.job.url'; +export const CI_JOB_NAME = 'ci.job.name'; +export const CI_STAGE_NAME = 'ci.stage.name'; +export const CI_NODE_NAME = 'ci.node.name'; +export const CI_NODE_LABELS = 'ci.node.labels'; +export const CI_ENV_VARS = '_dd.ci.env_vars'; + +// For Git. +export const GIT_BRANCH = 'git.branch'; +export const GIT_COMMIT_AUTHOR_DATE = 'git.commit.author.date'; +export const GIT_COMMIT_AUTHOR_EMAIL = 'git.commit.author.email'; +export const GIT_COMMIT_AUTHOR_NAME = 'git.commit.author.name'; +export const GIT_COMMIT_COMMITTER_DATE = 'git.commit.committer.date'; +export const GIT_COMMIT_COMMITTER_EMAIL = 'git.commit.committer.email'; +export const GIT_COMMIT_COMMITTER_NAME = 'git.commit.committer.name'; +export const GIT_COMMIT_MESSAGE = 'git.commit.message'; +export const GIT_SHA = 'git.commit.sha'; +export const GIT_TAG = 'git.tag'; +export const GIT_HEAD_SHA = 'git.commit.head_sha'; +export const GIT_BASE_REF = 'git.commit.base_ref'; +export const GIT_PULL_REQUEST_BASE_BRANCH_SHA = 'git.pull_request.base_branch_sha'; +export const GIT_PULL_REQUEST_BASE_BRANCH = 'git.pull_request.base_branch'; + +// For the plugin. +export const BUILD_PLUGIN_VERSION = 'build.plugin.version'; +export const BUILD_PLUGIN_ENV = 'build.plugin.env'; +export const BUILD_PLUGIN_BUNDLER_NAME = 'build.bundler.name'; +export const BUILD_PLUGIN_BUNDLER_VERSION = 'build.bundler.version'; +export const BUILD_PLUGIN_SPAN_PREFIX = 'build.span'; + +// Intake +export const INTAKE_HOST = 'app.datadoghq.com'; +export const INTAKE_PATH = 'api/intake/ci/custom_spans'; diff --git a/packages/plugins/ci-visibility/src/helpers/buildSpansPlugin.ts b/packages/plugins/ci-visibility/src/helpers/buildSpansPlugin.ts new file mode 100644 index 000000000..36fdcdd98 --- /dev/null +++ b/packages/plugins/ci-visibility/src/helpers/buildSpansPlugin.ts @@ -0,0 +1,99 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { shouldGetGitInfo } from '@dd/core/helpers/plugins'; +import type { GlobalContext, Options, PluginName, PluginOptions } from '@dd/core/types'; +import { PLUGIN_NAME as BUILD_REPORT_PLUGIN_NAME } from '@dd/internal-build-report-plugin'; + +export const BUILD_SPANS_PLUGIN_NAME: PluginName = 'datadog-ci-visibility-build-spans-plugin'; + +export const getBuildSpansPlugin = (context: GlobalContext, options: Options): PluginOptions => { + const log = context.getLogger(BUILD_SPANS_PLUGIN_NAME); + + const timeBuildReport = log.time('Build report', { start: false }); + const timeGit = log.time('Git', { start: false }); + const timeHold = log.time('Hold', { start: context.start }); + const timeTotal = log.time('Total time', { start: context.start }); + const timeInit = log.time('Datadog plugins initialization', { start: context.start }); + const timeBuild = log.time('Build', { start: false }); + const timeWrite = log.time('Write', { start: false }); + const timeLoad = log.time('Load', { start: false }); + const timeTransform = log.time('Transform', { start: false }); + + let lastTransformTime = context.start; + let lastWriteTime = context.start; + + return { + name: BUILD_SPANS_PLUGIN_NAME, + enforce: 'pre', + init() { + timeInit.end(); + }, + buildStart() { + timeHold.end(); + timeBuild.resume(); + if (shouldGetGitInfo(options)) { + timeGit.resume(); + } + }, + git() { + timeGit.end(); + }, + loadInclude() { + return true; + }, + load() { + timeLoad.resume(); + return null; + }, + transformInclude() { + return true; + }, + transform() { + timeTransform.resume(); + lastTransformTime = Date.now(); + return null; + }, + buildEnd() { + timeLoad.end(); + timeTransform.end(); + timeBuild.end(); + timeWrite.resume(); + }, + writeBundle() { + lastWriteTime = Date.now(); + }, + buildReport() { + for (const timing of context.build.timings) { + if ( + timing.pluginName !== BUILD_REPORT_PLUGIN_NAME || + timing.label !== 'build report' + ) { + continue; + } + + // Copy build report spans to our own logger. + for (const span of timing.spans) { + const end = span.end || Date.now(); + timeBuildReport.resume(span.start); + timeBuildReport.pause(end); + } + } + }, + asyncTrueEnd() { + // esbuild may not call buildEnd in time to define the write phase. + // So lets simulate this from the last transform time. + // This is a bit of a hack, but it's better than nothing. + if (context.bundler.fullName === 'esbuild') { + if (!timeWrite.timer.spans.length) { + timeWrite.resume(lastTransformTime); + } + } + }, + syncTrueEnd() { + timeWrite.end(lastWriteTime); + timeTotal.end(); + }, + }; +}; diff --git a/packages/plugins/ci-visibility/src/helpers/ciSpanTags.ts b/packages/plugins/ci-visibility/src/helpers/ciSpanTags.ts new file mode 100644 index 000000000..9593edd41 --- /dev/null +++ b/packages/plugins/ci-visibility/src/helpers/ciSpanTags.ts @@ -0,0 +1,763 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { readJsonSync } from '@dd/core/helpers/fs'; +import { filterSensitiveInfoFromRepositoryUrl } from '@dd/core/helpers/strings'; + +import { + GIT_COMMIT_MESSAGE, + GIT_COMMIT_AUTHOR_EMAIL, + GIT_COMMIT_AUTHOR_NAME, + GIT_BRANCH, + GIT_TAG, + CI_WORKSPACE_PATH, + GIT_SHA, + CI_JOB_NAME, + CI_NODE_LABELS, + CI_NODE_NAME, + CI_ENV_VARS, + CI_PIPELINE_URL, + GIT_REPOSITORY_URL, + CI_PROVIDER_NAME, + CI_PIPELINE_NUMBER, + CI_STAGE_NAME, + CI_JOB_URL, + CI_PIPELINE_ID, + CI_PIPELINE_NAME, + CI_ENGINES, + GIT_COMMIT_AUTHOR_DATE, + GIT_PULL_REQUEST_BASE_BRANCH, + GIT_BASE_REF, + GIT_HEAD_SHA, + GIT_PULL_REQUEST_BASE_BRANCH_SHA, + GIT_COMMIT_COMMITTER_EMAIL, + GIT_COMMIT_COMMITTER_NAME, +} from '../constants'; +import type { SpanTags } from '../types'; + +// Receives a string with the form 'John Doe ' +// and returns { name: 'John Doe', email: 'john.doe@gmail.com' } +// Exported for testing purposes. +export const parseEmailAndName = (emailAndName: string | undefined) => { + if (!emailAndName) { + return { name: '', email: '' }; + } + let name = ''; + let email = ''; + const matchNameAndEmail = emailAndName.match(/(?:"?([^"]*)"?\s)?(?:]+)>?)/); + if (matchNameAndEmail) { + name = matchNameAndEmail[1]; + email = matchNameAndEmail[2]; + } + + return { name, email }; +}; + +type GitHubWebhookPayload = { + pull_request?: { + head?: { + sha: string; + }; + base?: { + sha: string; + }; + }; +}; +const getGitHubEventPayload = () => { + if (!process.env.GITHUB_EVENT_PATH) { + return; + } + + return readJsonSync(process.env.GITHUB_EVENT_PATH) as GitHubWebhookPayload; +}; + +// Normalize a ref. +export const normalizeRef = (ref: string | undefined) => { + if (!ref) { + return ref; + } + + return ref.replace(/origin\/|refs\/heads\/|tags\//gm, ''); +}; + +// Resolve a tilde in a file path. +const resolveTilde = (filePath: string | undefined) => { + if (!filePath || typeof filePath !== 'string') { + return ''; + } + // '~/folder/path' or '~' + if (filePath[0] === '~' && (filePath[1] === '/' || filePath.length === 1)) { + return filePath.replace('~', process.env.HOME ?? ''); + } + + return filePath; +}; + +// Remove empty values from the tags object. +const removeEmptyValues = (tags: SpanTags) => { + return Object.fromEntries( + Object.entries(tags).filter( + ([_, value]) => value !== null && value !== undefined && value !== '', + ), + ); +}; + +export const getCIProvider = (): string => { + if (process.env.CIRCLECI) { + return CI_ENGINES.CIRCLECI; + } + + if (process.env.GITLAB_CI) { + return CI_ENGINES.GITLAB; + } + + if (process.env.GITHUB_ACTIONS || process.env.GITHUB_ACTION) { + return CI_ENGINES.GITHUB; + } + + if (process.env.BUILDKITE) { + return CI_ENGINES.BUILDKITE; + } + + if (process.env.BUDDY) { + return CI_ENGINES.BUDDY; + } + + if (process.env.TEAMCITY_VERSION) { + return CI_ENGINES.TEAMCITY; + } + + if (process.env.JENKINS_URL) { + return CI_ENGINES.JENKINS; + } + + if (process.env.TF_BUILD) { + return CI_ENGINES.AZURE; + } + + if (process.env.CF_BUILD_ID) { + return CI_ENGINES.CODEFRESH; + } + + if (process.env.APPVEYOR) { + return CI_ENGINES.APPVEYOR; + } + + if (process.env.BITBUCKET_COMMIT) { + return CI_ENGINES.BITBUCKET; + } + + if (process.env.BITRISE_BUILD_SLUG) { + return CI_ENGINES.BITRISE; + } + + if (process.env.CODEBUILD_INITIATOR?.startsWith('codepipeline')) { + return CI_ENGINES.AWSCODEPIPELINE; + } + + return CI_ENGINES.UNKNOWN; +}; + +export const getCISpanTags = (): SpanTags => { + const env = process.env; + let tags: SpanTags = {}; + + if (env.DRONE) { + const { + DRONE_BUILD_NUMBER, + DRONE_BUILD_LINK, + DRONE_STEP_NAME, + DRONE_STAGE_NAME, + DRONE_WORKSPACE, + DRONE_GIT_HTTP_URL, + DRONE_COMMIT_SHA, + DRONE_BRANCH, + DRONE_TAG, + DRONE_COMMIT_AUTHOR_NAME, + DRONE_COMMIT_AUTHOR_EMAIL, + DRONE_COMMIT_MESSAGE, + } = env; + tags = { + [CI_PROVIDER_NAME]: 'drone', + [CI_PIPELINE_NUMBER]: DRONE_BUILD_NUMBER, + [CI_PIPELINE_URL]: DRONE_BUILD_LINK, + [CI_JOB_NAME]: DRONE_STEP_NAME, + [CI_STAGE_NAME]: DRONE_STAGE_NAME, + [CI_WORKSPACE_PATH]: DRONE_WORKSPACE, + [GIT_REPOSITORY_URL]: DRONE_GIT_HTTP_URL, + [GIT_SHA]: DRONE_COMMIT_SHA, + [GIT_BRANCH]: DRONE_BRANCH, + [GIT_TAG]: DRONE_TAG, + [GIT_COMMIT_AUTHOR_NAME]: DRONE_COMMIT_AUTHOR_NAME, + [GIT_COMMIT_AUTHOR_EMAIL]: DRONE_COMMIT_AUTHOR_EMAIL, + [GIT_COMMIT_MESSAGE]: DRONE_COMMIT_MESSAGE, + }; + } + + if (env.CIRCLECI) { + const { + CIRCLE_BUILD_NUM, + CIRCLE_WORKFLOW_ID, + CIRCLE_PROJECT_REPONAME, + CIRCLE_BUILD_URL, + CIRCLE_WORKING_DIRECTORY, + CIRCLE_BRANCH, + CIRCLE_TAG, + CIRCLE_SHA1, + CIRCLE_REPOSITORY_URL, + CIRCLE_JOB, + } = env; + + const pipelineUrl = `https://app.circleci.com/pipelines/workflows/${CIRCLE_WORKFLOW_ID}`; + + tags = { + [CI_JOB_URL]: CIRCLE_BUILD_URL, + [CI_PIPELINE_ID]: CIRCLE_WORKFLOW_ID, + [CI_PIPELINE_NAME]: CIRCLE_PROJECT_REPONAME, + [CI_PIPELINE_URL]: pipelineUrl, + [CI_JOB_NAME]: CIRCLE_JOB, + [CI_PROVIDER_NAME]: CI_ENGINES.CIRCLECI, + [CI_WORKSPACE_PATH]: CIRCLE_WORKING_DIRECTORY, + [GIT_SHA]: CIRCLE_SHA1, + [GIT_REPOSITORY_URL]: CIRCLE_REPOSITORY_URL, + [GIT_TAG]: CIRCLE_TAG, + [GIT_BRANCH]: CIRCLE_BRANCH, + [CI_ENV_VARS]: JSON.stringify({ + CIRCLE_WORKFLOW_ID, + // Snapshots are generated automatically and are sort sensitive + CIRCLE_BUILD_NUM, + }), + }; + } + + if (env.TRAVIS) { + const { + TRAVIS_PULL_REQUEST_BRANCH, + TRAVIS_BRANCH, + TRAVIS_COMMIT, + TRAVIS_REPO_SLUG, + TRAVIS_TAG, + TRAVIS_JOB_WEB_URL, + TRAVIS_BUILD_ID, + TRAVIS_BUILD_NUMBER, + TRAVIS_BUILD_WEB_URL, + TRAVIS_BUILD_DIR, + TRAVIS_COMMIT_MESSAGE, + } = env; + tags = { + [CI_JOB_URL]: TRAVIS_JOB_WEB_URL, + [CI_PIPELINE_ID]: TRAVIS_BUILD_ID, + [CI_PIPELINE_NAME]: TRAVIS_REPO_SLUG, + [CI_PIPELINE_NUMBER]: TRAVIS_BUILD_NUMBER, + [CI_PIPELINE_URL]: TRAVIS_BUILD_WEB_URL, + [CI_PROVIDER_NAME]: CI_ENGINES.TRAVIS, + [CI_WORKSPACE_PATH]: TRAVIS_BUILD_DIR, + [GIT_SHA]: TRAVIS_COMMIT, + [GIT_TAG]: TRAVIS_TAG, + [GIT_BRANCH]: TRAVIS_PULL_REQUEST_BRANCH || TRAVIS_BRANCH, + [GIT_REPOSITORY_URL]: `https://github.com/${TRAVIS_REPO_SLUG}.git`, + [GIT_COMMIT_MESSAGE]: TRAVIS_COMMIT_MESSAGE, + }; + } + + if (env.GITLAB_CI) { + const { + CI_PIPELINE_ID: GITLAB_CI_PIPELINE_ID, + CI_PROJECT_PATH, + CI_PIPELINE_IID, + CI_PIPELINE_URL: GITLAB_CI_PIPELINE_URL, + CI_PROJECT_DIR, + CI_COMMIT_REF_NAME, + CI_COMMIT_TAG, + CI_COMMIT_SHA, + CI_REPOSITORY_URL, + CI_JOB_URL: GITLAB_CI_JOB_URL, + CI_JOB_STAGE, + CI_JOB_NAME: GITLAB_CI_JOB_NAME, + CI_COMMIT_MESSAGE, + CI_COMMIT_TIMESTAMP, + CI_COMMIT_AUTHOR, + CI_JOB_ID: GITLAB_CI_JOB_ID, + CI_PROJECT_URL: GITLAB_CI_PROJECT_URL, + CI_RUNNER_ID, + CI_RUNNER_TAGS, + } = env; + + const { name, email } = parseEmailAndName(CI_COMMIT_AUTHOR); + + tags = { + [CI_JOB_NAME]: GITLAB_CI_JOB_NAME, + [CI_JOB_URL]: GITLAB_CI_JOB_URL, + [CI_PIPELINE_ID]: GITLAB_CI_PIPELINE_ID, + [CI_PIPELINE_NAME]: CI_PROJECT_PATH, + [CI_PIPELINE_NUMBER]: CI_PIPELINE_IID, + [CI_PIPELINE_URL]: GITLAB_CI_PIPELINE_URL, + [CI_PROVIDER_NAME]: CI_ENGINES.GITLAB, + [CI_WORKSPACE_PATH]: CI_PROJECT_DIR, + [CI_STAGE_NAME]: CI_JOB_STAGE, + [GIT_BRANCH]: CI_COMMIT_REF_NAME, + [GIT_SHA]: CI_COMMIT_SHA, + [GIT_REPOSITORY_URL]: CI_REPOSITORY_URL, + [GIT_TAG]: CI_COMMIT_TAG, + [GIT_COMMIT_MESSAGE]: CI_COMMIT_MESSAGE, + [GIT_COMMIT_AUTHOR_NAME]: name, + [GIT_COMMIT_AUTHOR_EMAIL]: email, + [GIT_COMMIT_AUTHOR_DATE]: CI_COMMIT_TIMESTAMP, + [CI_ENV_VARS]: JSON.stringify({ + CI_PROJECT_URL: GITLAB_CI_PROJECT_URL, + // Snapshots are generated automatically and are sort sensitive + CI_PIPELINE_ID: GITLAB_CI_PIPELINE_ID, + CI_JOB_ID: GITLAB_CI_JOB_ID, + }), + [CI_NODE_LABELS]: CI_RUNNER_TAGS, + [CI_NODE_NAME]: CI_RUNNER_ID, + }; + } + + if (env.GITHUB_ACTIONS || env.GITHUB_ACTION) { + const { + GITHUB_RUN_ID, + GITHUB_WORKFLOW, + GITHUB_RUN_NUMBER, + GITHUB_WORKSPACE, + GITHUB_HEAD_REF, + GITHUB_JOB, + GITHUB_REF, + GITHUB_SHA, + GITHUB_REPOSITORY, + GITHUB_SERVER_URL, + GITHUB_RUN_ATTEMPT, + DD_GITHUB_JOB_NAME, + GITHUB_BASE_REF, + } = env; + const repositoryUrl = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git`; + let pipelineURL = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`; + + // Some older versions of enterprise might not have this yet. + if (GITHUB_RUN_ATTEMPT) { + pipelineURL += `/attempts/${GITHUB_RUN_ATTEMPT}`; + } + + tags = { + [CI_JOB_NAME]: GITHUB_JOB, + [CI_JOB_URL]: filterSensitiveInfoFromRepositoryUrl( + `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}/checks`, + ), + [CI_PIPELINE_ID]: GITHUB_RUN_ID, + [CI_PIPELINE_NAME]: GITHUB_WORKFLOW, + [CI_PIPELINE_NUMBER]: GITHUB_RUN_NUMBER, + [CI_PIPELINE_URL]: filterSensitiveInfoFromRepositoryUrl(pipelineURL), + [CI_PROVIDER_NAME]: CI_ENGINES.GITHUB, + [CI_WORKSPACE_PATH]: GITHUB_WORKSPACE, + [GIT_SHA]: GITHUB_SHA, + [GIT_REPOSITORY_URL]: repositoryUrl, + [GIT_BRANCH]: GITHUB_HEAD_REF || GITHUB_REF || '', + [CI_ENV_VARS]: JSON.stringify({ + GITHUB_SERVER_URL: filterSensitiveInfoFromRepositoryUrl(GITHUB_SERVER_URL), + // Snapshots are generated automatically and are sort sensitive + GITHUB_REPOSITORY, + GITHUB_RUN_ID, + GITHUB_RUN_ATTEMPT, + DD_GITHUB_JOB_NAME, + }), + }; + + if (GITHUB_BASE_REF) { + // GITHUB_BASE_REF is defined if it's a pull_request or pull_request_target trigger + tags[GIT_BASE_REF] = GITHUB_BASE_REF; + tags[GIT_PULL_REQUEST_BASE_BRANCH] = GITHUB_BASE_REF; + try { + const eventPayload = getGitHubEventPayload(); + tags[GIT_HEAD_SHA] = eventPayload?.pull_request?.head?.sha; + tags[GIT_PULL_REQUEST_BASE_BRANCH_SHA] = eventPayload?.pull_request?.base?.sha; + } catch (e) { + // ignore malformed event content + } + } + } + + if (env.JENKINS_URL) { + const { + WORKSPACE, + BUILD_TAG, + JOB_NAME, + BUILD_NUMBER, + BUILD_URL, + GIT_BRANCH: JENKINS_GIT_BRANCH, + GIT_COMMIT, + GIT_URL, + GIT_URL_1, + DD_CUSTOM_TRACE_ID, + DD_CUSTOM_PARENT_ID, + NODE_NAME, + NODE_LABELS, + } = env; + + tags = { + [CI_PIPELINE_ID]: BUILD_TAG, + [CI_PIPELINE_NUMBER]: BUILD_NUMBER, + [CI_PIPELINE_URL]: BUILD_URL, + [CI_PROVIDER_NAME]: CI_ENGINES.JENKINS, + [CI_WORKSPACE_PATH]: WORKSPACE, + [GIT_SHA]: GIT_COMMIT, + [GIT_REPOSITORY_URL]: GIT_URL || GIT_URL_1, + [GIT_BRANCH]: JENKINS_GIT_BRANCH, + [CI_NODE_NAME]: NODE_NAME, + [CI_ENV_VARS]: JSON.stringify({ + DD_CUSTOM_TRACE_ID, + DD_CUSTOM_PARENT_ID, + }), + }; + + if (NODE_LABELS) { + let nodeLabels; + try { + nodeLabels = JSON.stringify(NODE_LABELS.split(' ')); + tags[CI_NODE_LABELS] = nodeLabels; + } catch (e) { + // ignore errors + } + } + + let finalPipelineName = ''; + if (JOB_NAME) { + // Job names can contain parameters, e.g. jobName/KEY1=VALUE1,KEY2=VALUE2/branchName + const jobNameAndParams = JOB_NAME.split('/'); + if (jobNameAndParams.length > 1 && jobNameAndParams[1].includes('=')) { + finalPipelineName = jobNameAndParams[0]; + } else { + const normalizedBranch = normalizeRef(JENKINS_GIT_BRANCH); + finalPipelineName = JOB_NAME.replace(`/${normalizedBranch}`, ''); + } + tags[CI_PIPELINE_NAME] = finalPipelineName; + } + } + + if (env.BUILDKITE) { + const { + BUILDKITE_AGENT_ID, + BUILDKITE_BRANCH, + BUILDKITE_COMMIT, + BUILDKITE_REPO, + BUILDKITE_TAG, + BUILDKITE_BUILD_ID, + BUILDKITE_PIPELINE_SLUG, + BUILDKITE_BUILD_NUMBER, + BUILDKITE_BUILD_URL, + BUILDKITE_JOB_ID, + BUILDKITE_BUILD_CHECKOUT_PATH, + BUILDKITE_BUILD_AUTHOR, + BUILDKITE_BUILD_AUTHOR_EMAIL, + BUILDKITE_MESSAGE, + } = env; + + const extraTags = Object.keys(env) + .filter((envVar) => envVar.startsWith('BUILDKITE_AGENT_META_DATA_')) + .map((metadataKey) => { + const key = metadataKey.replace('BUILDKITE_AGENT_META_DATA_', '').toLowerCase(); + + return `${key}:${env[metadataKey]}`; + }); + + tags = { + [CI_NODE_NAME]: BUILDKITE_AGENT_ID, + [CI_PROVIDER_NAME]: CI_ENGINES.BUILDKITE, + [CI_PIPELINE_ID]: BUILDKITE_BUILD_ID, + [CI_PIPELINE_NAME]: BUILDKITE_PIPELINE_SLUG, + [CI_PIPELINE_NUMBER]: BUILDKITE_BUILD_NUMBER, + [CI_PIPELINE_URL]: BUILDKITE_BUILD_URL, + [CI_JOB_URL]: `${BUILDKITE_BUILD_URL}#${BUILDKITE_JOB_ID}`, + [GIT_SHA]: BUILDKITE_COMMIT, + [CI_WORKSPACE_PATH]: BUILDKITE_BUILD_CHECKOUT_PATH, + [GIT_REPOSITORY_URL]: BUILDKITE_REPO, + [GIT_TAG]: BUILDKITE_TAG, + [GIT_BRANCH]: BUILDKITE_BRANCH, + [GIT_COMMIT_AUTHOR_NAME]: BUILDKITE_BUILD_AUTHOR, + [GIT_COMMIT_AUTHOR_EMAIL]: BUILDKITE_BUILD_AUTHOR_EMAIL, + [GIT_COMMIT_MESSAGE]: BUILDKITE_MESSAGE, + [CI_ENV_VARS]: JSON.stringify({ + BUILDKITE_BUILD_ID, + BUILDKITE_JOB_ID, + }), + }; + if (extraTags.length) { + tags[CI_NODE_LABELS] = JSON.stringify(extraTags); + } + } + + if (env.BITRISE_BUILD_SLUG) { + const { + BITRISE_GIT_COMMIT, + GIT_CLONE_COMMIT_HASH, + BITRISEIO_GIT_BRANCH_DEST, + BITRISE_GIT_BRANCH, + BITRISE_BUILD_SLUG, + BITRISE_TRIGGERED_WORKFLOW_ID, + BITRISE_BUILD_NUMBER, + BITRISE_BUILD_URL, + BITRISE_SOURCE_DIR, + GIT_REPOSITORY_URL: BITRISE_GIT_REPOSITORY_URL, + BITRISE_GIT_TAG, + BITRISE_GIT_MESSAGE, + } = env; + + tags = { + [CI_PROVIDER_NAME]: CI_ENGINES.BITRISE, + [CI_PIPELINE_ID]: BITRISE_BUILD_SLUG, + [CI_PIPELINE_NAME]: BITRISE_TRIGGERED_WORKFLOW_ID, + [CI_PIPELINE_NUMBER]: BITRISE_BUILD_NUMBER, + [CI_PIPELINE_URL]: BITRISE_BUILD_URL, + [GIT_SHA]: BITRISE_GIT_COMMIT || GIT_CLONE_COMMIT_HASH, + [GIT_REPOSITORY_URL]: BITRISE_GIT_REPOSITORY_URL, + [CI_WORKSPACE_PATH]: BITRISE_SOURCE_DIR, + [GIT_TAG]: BITRISE_GIT_TAG, + [GIT_BRANCH]: BITRISEIO_GIT_BRANCH_DEST || BITRISE_GIT_BRANCH, + [GIT_COMMIT_MESSAGE]: BITRISE_GIT_MESSAGE, + }; + } + + if (env.BITBUCKET_COMMIT) { + const { + BITBUCKET_REPO_FULL_NAME, + BITBUCKET_BUILD_NUMBER, + BITBUCKET_BRANCH, + BITBUCKET_COMMIT, + BITBUCKET_GIT_SSH_ORIGIN, + BITBUCKET_GIT_HTTP_ORIGIN, + BITBUCKET_TAG, + BITBUCKET_PIPELINE_UUID, + BITBUCKET_CLONE_DIR, + } = env; + + const url = `https://bitbucket.org/${BITBUCKET_REPO_FULL_NAME}/addon/pipelines/home#!/results/${BITBUCKET_BUILD_NUMBER}`; + + tags = { + [CI_PROVIDER_NAME]: CI_ENGINES.BITBUCKET, + [GIT_SHA]: BITBUCKET_COMMIT, + [CI_PIPELINE_NUMBER]: BITBUCKET_BUILD_NUMBER, + [CI_PIPELINE_NAME]: BITBUCKET_REPO_FULL_NAME, + [CI_JOB_URL]: url, + [CI_PIPELINE_URL]: url, + [GIT_BRANCH]: BITBUCKET_BRANCH, + [GIT_TAG]: BITBUCKET_TAG, + [GIT_REPOSITORY_URL]: BITBUCKET_GIT_SSH_ORIGIN || BITBUCKET_GIT_HTTP_ORIGIN, + [CI_WORKSPACE_PATH]: BITBUCKET_CLONE_DIR, + [CI_PIPELINE_ID]: + BITBUCKET_PIPELINE_UUID && BITBUCKET_PIPELINE_UUID.replace(/{|}/gm, ''), + }; + } + + if (env.CF_BUILD_ID) { + const { CF_BUILD_ID, CF_PIPELINE_NAME, CF_BUILD_URL, CF_STEP_NAME, CF_BRANCH } = env; + + tags = { + [CI_PROVIDER_NAME]: CI_ENGINES.CODEFRESH, + [CI_PIPELINE_ID]: CF_BUILD_ID, + [CI_PIPELINE_URL]: CF_BUILD_URL, + [CI_PIPELINE_NAME]: CF_PIPELINE_NAME, + [CI_JOB_NAME]: CF_STEP_NAME, + [GIT_BRANCH]: CF_BRANCH, + [CI_ENV_VARS]: JSON.stringify({ CF_BUILD_ID }), + }; + } + + if (env.TEAMCITY_VERSION) { + const { BUILD_URL, TEAMCITY_BUILDCONF_NAME } = env; + + tags = { + [CI_PROVIDER_NAME]: CI_ENGINES.TEAMCITY, + [CI_JOB_URL]: BUILD_URL, + [CI_JOB_NAME]: TEAMCITY_BUILDCONF_NAME, + }; + } + + if (env.TF_BUILD) { + const { + BUILD_SOURCESDIRECTORY, + BUILD_BUILDID, + BUILD_DEFINITIONNAME, + SYSTEM_TEAMFOUNDATIONSERVERURI, + SYSTEM_TEAMPROJECTID, + SYSTEM_JOBID, + SYSTEM_TASKINSTANCEID, + SYSTEM_PULLREQUEST_SOURCEBRANCH, + BUILD_SOURCEBRANCH, + BUILD_SOURCEBRANCHNAME, + SYSTEM_PULLREQUEST_SOURCECOMMITID, + SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI, + BUILD_REPOSITORY_URI, + BUILD_SOURCEVERSION, + BUILD_REQUESTEDFORID, + BUILD_REQUESTEDFOREMAIL, + BUILD_SOURCEVERSIONMESSAGE, + SYSTEM_STAGEDISPLAYNAME, + SYSTEM_JOBDISPLAYNAME, + } = env; + + tags = { + [CI_PROVIDER_NAME]: CI_ENGINES.AZURE, + [CI_PIPELINE_ID]: BUILD_BUILDID, + [CI_PIPELINE_NAME]: BUILD_DEFINITIONNAME, + [CI_PIPELINE_NUMBER]: BUILD_BUILDID, + [GIT_SHA]: SYSTEM_PULLREQUEST_SOURCECOMMITID || BUILD_SOURCEVERSION, + [CI_WORKSPACE_PATH]: BUILD_SOURCESDIRECTORY, + [GIT_REPOSITORY_URL]: SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI || BUILD_REPOSITORY_URI, + [GIT_BRANCH]: + SYSTEM_PULLREQUEST_SOURCEBRANCH || BUILD_SOURCEBRANCH || BUILD_SOURCEBRANCHNAME, + [GIT_COMMIT_AUTHOR_NAME]: BUILD_REQUESTEDFORID, + [GIT_COMMIT_AUTHOR_EMAIL]: BUILD_REQUESTEDFOREMAIL, + [GIT_COMMIT_MESSAGE]: BUILD_SOURCEVERSIONMESSAGE, + [CI_STAGE_NAME]: SYSTEM_STAGEDISPLAYNAME, + [CI_JOB_NAME]: SYSTEM_JOBDISPLAYNAME, + [CI_ENV_VARS]: JSON.stringify({ + SYSTEM_TEAMPROJECTID, + BUILD_BUILDID, + SYSTEM_JOBID, + }), + }; + + if (SYSTEM_TEAMFOUNDATIONSERVERURI && SYSTEM_TEAMPROJECTID && BUILD_BUILDID) { + const baseUrl = `${SYSTEM_TEAMFOUNDATIONSERVERURI}${SYSTEM_TEAMPROJECTID}/_build/results?buildId=${BUILD_BUILDID}`; + const pipelineUrl = baseUrl; + const jobUrl = `${baseUrl}&view=logs&j=${SYSTEM_JOBID}&t=${SYSTEM_TASKINSTANCEID}`; + + tags = { + ...tags, + [CI_PIPELINE_URL]: pipelineUrl, + [CI_JOB_URL]: jobUrl, + }; + } + } + + if (env.APPVEYOR) { + const { + APPVEYOR_REPO_NAME, + APPVEYOR_REPO_PROVIDER, + APPVEYOR_BUILD_FOLDER, + APPVEYOR_BUILD_ID, + APPVEYOR_BUILD_NUMBER, + APPVEYOR_REPO_COMMIT, + APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH, + APPVEYOR_REPO_BRANCH, + APPVEYOR_REPO_TAG_NAME, + APPVEYOR_REPO_COMMIT_AUTHOR, + APPVEYOR_REPO_COMMIT_AUTHOR_EMAIL, + APPVEYOR_REPO_COMMIT_MESSAGE, + APPVEYOR_REPO_COMMIT_MESSAGE_EXTENDED, + } = env; + + const pipelineUrl = `https://ci.appveyor.com/project/${APPVEYOR_REPO_NAME}/builds/${APPVEYOR_BUILD_ID}`; + + tags = { + [CI_PROVIDER_NAME]: CI_ENGINES.APPVEYOR, + [CI_PIPELINE_URL]: pipelineUrl, + [CI_PIPELINE_ID]: APPVEYOR_BUILD_ID, + [CI_PIPELINE_NAME]: APPVEYOR_REPO_NAME, + [CI_PIPELINE_NUMBER]: APPVEYOR_BUILD_NUMBER, + [CI_JOB_URL]: pipelineUrl, + [CI_WORKSPACE_PATH]: APPVEYOR_BUILD_FOLDER, + [GIT_COMMIT_AUTHOR_NAME]: APPVEYOR_REPO_COMMIT_AUTHOR, + [GIT_COMMIT_AUTHOR_EMAIL]: APPVEYOR_REPO_COMMIT_AUTHOR_EMAIL, + [GIT_COMMIT_MESSAGE]: `${APPVEYOR_REPO_COMMIT_MESSAGE || ''}\n${APPVEYOR_REPO_COMMIT_MESSAGE_EXTENDED || ''}`, + }; + + if (APPVEYOR_REPO_PROVIDER === 'github') { + tags = { + ...tags, + [GIT_REPOSITORY_URL]: `https://github.com/${APPVEYOR_REPO_NAME}.git`, + [GIT_SHA]: APPVEYOR_REPO_COMMIT, + [GIT_TAG]: APPVEYOR_REPO_TAG_NAME, + [GIT_BRANCH]: APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH || APPVEYOR_REPO_BRANCH, + }; + } + } + + if (env.BUDDY) { + const { + BUDDY_PIPELINE_NAME, + BUDDY_PIPELINE_ID, + BUDDY_EXECUTION_ID, + BUDDY_SCM_URL, + BUDDY_EXECUTION_BRANCH, + BUDDY_EXECUTION_TAG, + BUDDY_EXECUTION_REVISION, + BUDDY_EXECUTION_URL, + BUDDY_EXECUTION_REVISION_MESSAGE, + BUDDY_EXECUTION_REVISION_COMMITTER_NAME, + BUDDY_EXECUTION_REVISION_COMMITTER_EMAIL, + } = env; + + tags = { + [CI_PROVIDER_NAME]: CI_ENGINES.BUDDY, + [CI_PIPELINE_ID]: `${BUDDY_PIPELINE_ID || ''}/${BUDDY_EXECUTION_ID || ''}`, + [CI_PIPELINE_NAME]: BUDDY_PIPELINE_NAME, + [CI_PIPELINE_NUMBER]: `${BUDDY_EXECUTION_ID || ''}`, // gets parsed to int again later using parsePipelineNumber + [CI_PIPELINE_URL]: BUDDY_EXECUTION_URL, + [GIT_SHA]: BUDDY_EXECUTION_REVISION, + [GIT_BRANCH]: BUDDY_EXECUTION_BRANCH, + [GIT_TAG]: BUDDY_EXECUTION_TAG, + [GIT_REPOSITORY_URL]: BUDDY_SCM_URL, + [GIT_COMMIT_MESSAGE]: BUDDY_EXECUTION_REVISION_MESSAGE, + [GIT_COMMIT_COMMITTER_EMAIL]: BUDDY_EXECUTION_REVISION_COMMITTER_EMAIL, + [GIT_COMMIT_COMMITTER_NAME]: BUDDY_EXECUTION_REVISION_COMMITTER_NAME, + }; + } + + if (env.CF_BUILD_ID) { + const { CF_BUILD_ID, CF_PIPELINE_NAME, CF_BUILD_URL, CF_STEP_NAME, CF_BRANCH } = env; + tags = { + [CI_PROVIDER_NAME]: 'codefresh', + [CI_PIPELINE_ID]: CF_BUILD_ID, + [CI_PIPELINE_NAME]: CF_PIPELINE_NAME, + [CI_PIPELINE_URL]: CF_BUILD_URL, + [CI_JOB_NAME]: CF_STEP_NAME, + [CI_ENV_VARS]: JSON.stringify({ CF_BUILD_ID }), + }; + + const isTag = CF_BRANCH && CF_BRANCH.includes('tags/'); + const refKey = isTag ? GIT_TAG : GIT_BRANCH; + const ref = normalizeRef(CF_BRANCH); + + tags[refKey] = ref; + } + + if (env.CODEBUILD_INITIATOR?.startsWith('codepipeline')) { + const { CODEBUILD_BUILD_ARN, DD_ACTION_EXECUTION_ID, DD_PIPELINE_EXECUTION_ID } = env; + tags = { + [CI_PROVIDER_NAME]: CI_ENGINES.AWSCODEPIPELINE, + [CI_PIPELINE_ID]: DD_PIPELINE_EXECUTION_ID, + [CI_ENV_VARS]: JSON.stringify({ + CODEBUILD_BUILD_ARN, + DD_PIPELINE_EXECUTION_ID, + DD_ACTION_EXECUTION_ID, + }), + }; + } + + if (tags[CI_WORKSPACE_PATH]) { + tags[CI_WORKSPACE_PATH] = resolveTilde(tags[CI_WORKSPACE_PATH]); + } + if (tags[GIT_REPOSITORY_URL]) { + tags[GIT_REPOSITORY_URL] = filterSensitiveInfoFromRepositoryUrl(tags[GIT_REPOSITORY_URL]); + } + + if (tags[GIT_TAG]) { + tags[GIT_TAG] = normalizeRef(tags[GIT_TAG]); + } + + if (tags[GIT_BRANCH]) { + // Here we handle the case where GIT_BRANCH actually contains a tag + const branch = tags[GIT_BRANCH] || ''; + if (branch.startsWith('tags/') || branch.includes('/tags/')) { + if (!tags[GIT_TAG]) { + tags[GIT_TAG] = normalizeRef(branch); + } + tags[GIT_BRANCH] = ''; + } else { + tags[GIT_BRANCH] = normalizeRef(branch); + } + } + + return removeEmptyValues(tags); +}; diff --git a/packages/plugins/ci-visibility/src/helpers/customSpans.ts b/packages/plugins/ci-visibility/src/helpers/customSpans.ts new file mode 100644 index 000000000..28e06aa46 --- /dev/null +++ b/packages/plugins/ci-visibility/src/helpers/customSpans.ts @@ -0,0 +1,56 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { capitalize } from '@dd/core/helpers/strings'; +import type { GlobalContext } from '@dd/core/types'; +import crypto from 'crypto'; + +import type { CustomSpan } from '../types'; + +import { BUILD_SPANS_PLUGIN_NAME } from './buildSpansPlugin'; + +type SimpleSpan = Omit< + CustomSpan, + 'ci_provider' | 'span_id' | 'error_message' | 'exit_code' | 'measures' +>; + +export const getBuildName = (context: GlobalContext): string => { + return context.build.metadata?.name ? `"${context.build.metadata.name}"` : '"unknown build"'; +}; + +export const getCustomSpan = (provider: string, overrides: SimpleSpan): CustomSpan => ({ + ci_provider: provider, + span_id: crypto.randomBytes(5).toString('hex'), + error_message: '', + exit_code: 0, + measures: {}, + ...overrides, +}); + +export const getCustomSpans = (provider: string, context: GlobalContext): CustomSpan[] => { + const buildName = getBuildName(context); + const name = `Build of ${buildName} with ${capitalize(context.bundler.fullName)}`; + const spans: SimpleSpan[] = []; + + // Add all the spans from the time loggers. + for (const timing of context.build.timings) { + // Only add spans that are coming from our own plugin. + if (timing.pluginName !== BUILD_SPANS_PLUGIN_NAME) { + continue; + } + + for (const span of timing.spans) { + const end = span.end || Date.now(); + spans.push({ + command: `${name} | ${timing.pluginName} | ${capitalize(timing.label)}`, + name: `${capitalize(timing.label)}`, + start_time: new Date(span.start).toISOString(), + end_time: new Date(end).toISOString(), + tags: [`buildName:${buildName}`, ...timing.tags, ...span.tags], + }); + } + } + + return spans.map((span) => getCustomSpan(provider, span)); +}; diff --git a/packages/plugins/ci-visibility/src/helpers/parseTags.test.ts b/packages/plugins/ci-visibility/src/helpers/parseTags.test.ts new file mode 100644 index 000000000..6f86407cc --- /dev/null +++ b/packages/plugins/ci-visibility/src/helpers/parseTags.test.ts @@ -0,0 +1,84 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { BUILD_PLUGIN_SPAN_PREFIX } from '../constants'; + +import { parseTags } from './parseTags'; + +const testCases = [ + { + description: 'return an empty object when no tags are provided', + input: { spanTags: {}, tags: [] }, + expected: {}, + }, + { + description: 'add prefix to tags without it', + input: { spanTags: {}, tags: ['tag:value'] }, + expected: { [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value' }, + }, + { + description: 'not add prefix to tags that already have it', + input: { spanTags: {}, tags: [`${BUILD_PLUGIN_SPAN_PREFIX}.tag:value`] }, + expected: { [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value' }, + }, + { + description: 'merge values for the same tag', + input: { spanTags: {}, tags: ['tag:value1', 'tag:value2'] }, + expected: { [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value1,value2' }, + }, + { + description: 'deduplicate values for the same tag', + input: { spanTags: {}, tags: ['tag:value', 'tag:value'] }, + expected: { [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value' }, + }, + { + description: 'handle tags with multiple colons in value', + input: { spanTags: {}, tags: ['tag:value:with:colons'] }, + expected: { [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value:with:colons' }, + }, + { + description: 'preserve existing tags from spanTags', + input: { + spanTags: { existing: 'existingValue' }, + tags: ['tag:value'], + }, + expected: { + existing: 'existingValue', + [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value', + }, + }, + { + description: 'merge new tag values with existing tag values', + input: { + spanTags: { tag: 'existingValue' }, + tags: ['tag:newValue'], + }, + expected: { tag: 'existingValue,newValue' }, + }, + { + description: 'handle tags with spaces around the separator', + input: { spanTags: {}, tags: ['tag : value'] }, + expected: { [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value' }, + }, + { + description: 'skip empty values when converting sets to string', + input: { spanTags: { [`${BUILD_PLUGIN_SPAN_PREFIX}.empty`]: '' }, tags: [] }, + expected: {}, + }, + { + description: 'parse comma-separated values in existing tags', + input: { + spanTags: { [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value1,value2' }, + tags: ['tag:value3'], + }, + expected: { [`${BUILD_PLUGIN_SPAN_PREFIX}.tag`]: 'value1,value2,value3' }, + }, +]; + +describe('parseTags', () => { + test.each(testCases)('Should $description', ({ input, expected }) => { + const result = parseTags(input.spanTags, input.tags); + expect(result).toEqual(expected); + }); +}); diff --git a/packages/plugins/ci-visibility/src/helpers/parseTags.ts b/packages/plugins/ci-visibility/src/helpers/parseTags.ts new file mode 100644 index 000000000..4e294b793 --- /dev/null +++ b/packages/plugins/ci-visibility/src/helpers/parseTags.ts @@ -0,0 +1,54 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { LogTags } from '@dd/core/types'; + +import { BUILD_PLUGIN_SPAN_PREFIX } from '../constants'; +import type { SpanTag, SpanTags } from '../types'; + +export const parseTags = (spanTags: SpanTags, tags: LogTags): SpanTags => { + const parsedTags: SpanTags = {}; + const allTagsWithUniqueValues: Record> = {}; + + // Add the default tags to the temporary tags Sets. + for (const [key, value] of Object.entries(spanTags)) { + if (value) { + allTagsWithUniqueValues[key] = new Set(value.split(/ *, */g)); + } + } + + // Get all the tags and their (unique) values. + for (const tag of tags) { + const [key, ...rest] = tag.split(/ *: */g); + const verifiedKey = + key.startsWith(BUILD_PLUGIN_SPAN_PREFIX) || allTagsWithUniqueValues[key] + ? key + : `${BUILD_PLUGIN_SPAN_PREFIX}.${key}`; + const value = rest.join(':'); + + // If the value is already in the set, skip it. + if (allTagsWithUniqueValues[verifiedKey]?.has(value)) { + continue; + } + + // If the key doesn't exist, create a new set. + if (!allTagsWithUniqueValues[verifiedKey]) { + allTagsWithUniqueValues[verifiedKey] = new Set(); + } + + allTagsWithUniqueValues[verifiedKey].add(value); + } + + // Convert the sets into SpanTags. + for (const [key, value] of Object.entries(allTagsWithUniqueValues)) { + const stringValue = Array.from(value).join(','); + if (!stringValue) { + continue; + } + + parsedTags[key as SpanTag] = stringValue; + } + + return parsedTags; +}; diff --git a/packages/plugins/ci-visibility/src/helpers/sendSpans.ts b/packages/plugins/ci-visibility/src/helpers/sendSpans.ts new file mode 100644 index 000000000..46286e177 --- /dev/null +++ b/packages/plugins/ci-visibility/src/helpers/sendSpans.ts @@ -0,0 +1,92 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { doRequest, NB_RETRIES } from '@dd/core/helpers/request'; +import type { AuthOptions, Logger } from '@dd/core/types'; +import chalk from 'chalk'; +import PQueue from 'p-queue'; + +import { INTAKE_PATH, INTAKE_HOST } from '../constants'; +import type { CustomSpan, CustomSpanPayload, SpanTags } from '../types'; + +import { parseTags } from './parseTags'; + +const green = chalk.green.bold; +const yellow = chalk.yellow.bold; + +export const sendSpans = async ( + auth: AuthOptions, + payloads: CustomSpan[], + spanTags: SpanTags, + log: Logger, +) => { + const errors: string[] = []; + const warnings: string[] = []; + + if (!auth.apiKey) { + errors.push('No authentication token provided.'); + return { errors, warnings }; + } + + if (payloads.length === 0) { + warnings.push('No spans to submit.'); + return { errors, warnings }; + } + + // @ts-expect-error PQueue's default isn't typed. + const Queue = PQueue.default ? PQueue.default : PQueue; + const queue = new Queue({ concurrency: 20 }); + const addPromises = []; + + log.debug( + `Submitting ${green(payloads.length.toString())} span${payloads.length <= 1 ? '' : 's'}.`, + ); + for (const span of payloads) { + log.debug(`Queuing span ${green(span.name)}.`); + const spanToSubmit: CustomSpanPayload = { + ...span, + tags: parseTags(spanTags, span.tags), + }; + + addPromises.push( + queue.add(async () => { + try { + await doRequest({ + url: `https://${INTAKE_HOST}/${INTAKE_PATH}`, + auth: { apiKey: auth.apiKey }, + method: 'POST', + getData: () => { + const data = { + data: { + type: 'ci_app_custom_span', + attributes: spanToSubmit, + }, + }; + + return { + data: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + }, + }; + }, + // On retry we store the error as a warning. + onRetry: (error: Error, attempt: number) => { + const warningMessage = `Failed to submit span ${yellow(span.name)}:\n ${error.message}\nRetrying ${attempt}/${NB_RETRIES}`; + // This will be logged at the end of the process. + warnings.push(warningMessage); + }, + }); + log.debug(`Submitted span ${green(span.name)}.`); + } catch (e: any) { + errors.push(`Failed to submit span ${yellow(span.name)}:\n ${e.message}`); + } + }), + ); + } + + await Promise.all(addPromises); + await queue.onIdle(); + return { warnings, errors }; +}; diff --git a/packages/plugins/ci-visibility/src/index.test.ts b/packages/plugins/ci-visibility/src/index.test.ts new file mode 100644 index 000000000..a2d1d64e4 --- /dev/null +++ b/packages/plugins/ci-visibility/src/index.test.ts @@ -0,0 +1,69 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { getPlugins } from '@dd/ci-visibility-plugin'; +import { getContextMock } from '@dd/tests/_jest/helpers/mocks'; +import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; +import nock from 'nock'; + +import { INTAKE_PATH, INTAKE_HOST } from './constants'; + +describe('Ci Visibility Plugin', () => { + describe('getPlugins', () => { + test('Should not initialize the plugin if disabled', async () => { + expect( + getPlugins({ + options: { ciVisibility: { disabled: true } }, + context: getContextMock(), + bundler: {}, + }), + ).toHaveLength(0); + expect( + getPlugins({ options: {}, context: getContextMock(), bundler: {} }), + ).toHaveLength(0); + }); + + test('Should initialize the plugin if enabled', async () => { + expect( + getPlugins({ + options: { ciVisibility: {} }, + context: getContextMock(), + bundler: {}, + }).length, + ).toBeGreaterThan(0); + }); + }); + + describe('With a supported CI provider', () => { + const replyMock = jest.fn(() => ({})); + beforeAll(async () => { + nock(`https://${INTAKE_HOST}`) + // Intercept logs submissions. + .post(`/${INTAKE_PATH}`) + .reply(200, replyMock) + .persist(); + }); + + afterAll(async () => { + nock.cleanAll(); + }); + + test('Should send spans to Datadog', async () => { + // Spoof a github action. + process.env.GITHUB_ACTIONS = 'true'; + + const { errors } = await runBundlers({ + auth: { + apiKey: 'test', + }, + ciVisibility: {}, + }); + + expect(errors).toHaveLength(0); + expect(replyMock).toHaveBeenCalled(); + + delete process.env.GITHUB_ACTIONS; + }); + }); +}); diff --git a/packages/plugins/ci-visibility/src/index.ts b/packages/plugins/ci-visibility/src/index.ts new file mode 100644 index 000000000..7ce40e369 --- /dev/null +++ b/packages/plugins/ci-visibility/src/index.ts @@ -0,0 +1,134 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { GetPlugins, Options } from '@dd/core/types'; + +import { + CONFIG_KEY, + GIT_BRANCH, + GIT_COMMIT_AUTHOR_DATE, + GIT_COMMIT_AUTHOR_EMAIL, + GIT_COMMIT_AUTHOR_NAME, + GIT_COMMIT_COMMITTER_DATE, + GIT_COMMIT_COMMITTER_EMAIL, + GIT_COMMIT_COMMITTER_NAME, + GIT_COMMIT_MESSAGE, + GIT_REPOSITORY_URL, + GIT_SHA, + BUILD_PLUGIN_ENV, + BUILD_PLUGIN_VERSION, + BUILD_PLUGIN_BUNDLER_NAME, + BUILD_PLUGIN_BUNDLER_VERSION, + PLUGIN_NAME, + SUPPORTED_PROVIDERS, +} from './constants'; +import { getBuildSpansPlugin } from './helpers/buildSpansPlugin'; +import { getCIProvider, getCISpanTags } from './helpers/ciSpanTags'; +import { getCustomSpans } from './helpers/customSpans'; +import { sendSpans } from './helpers/sendSpans'; +import type { CiVisibilityOptions, CiVisibilityOptionsWithDefaults, SpanTags } from './types'; + +export { CONFIG_KEY, PLUGIN_NAME }; + +export const helpers = { + // Add the helpers you'd like to expose here. +}; + +export type types = { + // Add the types you'd like to expose here. + CiVisibilityOptions: CiVisibilityOptions; +}; + +// Deal with validation and defaults here. +export const validateOptions = (options: Options): CiVisibilityOptionsWithDefaults => { + const validatedOptions: CiVisibilityOptionsWithDefaults = { + disabled: !options[CONFIG_KEY], + ...options[CONFIG_KEY], + }; + + return validatedOptions; +}; + +export const getPlugins: GetPlugins = ({ options, context }) => { + const log = context.getLogger(PLUGIN_NAME); + // Verify configuration. + const validatedOptions = validateOptions(options); + + // If the plugin is disabled, return an empty array. + if (validatedOptions.disabled) { + return []; + } + + // Will populate with more tags as we get them. + const spanTags: SpanTags = getCISpanTags(); + + // Add basic tags. + spanTags[BUILD_PLUGIN_VERSION] = context.version; + spanTags[BUILD_PLUGIN_ENV] = context.env; + + return [ + getBuildSpansPlugin(context, options), + { + name: PLUGIN_NAME, + enforce: 'post', + git: (gitData) => { + // Add tags from git data. + spanTags[GIT_REPOSITORY_URL] = gitData.remote; + spanTags[GIT_BRANCH] = gitData.branch; + spanTags[GIT_SHA] = gitData.commit.hash; + spanTags[GIT_COMMIT_MESSAGE] = gitData.commit.message; + spanTags[GIT_COMMIT_AUTHOR_NAME] = gitData.commit.author.name; + spanTags[GIT_COMMIT_AUTHOR_EMAIL] = gitData.commit.author.email; + spanTags[GIT_COMMIT_AUTHOR_DATE] = gitData.commit.author.date; + spanTags[GIT_COMMIT_COMMITTER_NAME] = gitData.commit.committer.name; + spanTags[GIT_COMMIT_COMMITTER_EMAIL] = gitData.commit.committer.email; + spanTags[GIT_COMMIT_COMMITTER_DATE] = gitData.commit.committer.date; + }, + bundlerReport: (bundlerReport) => { + // Add custom tags from the bundler report. + spanTags[BUILD_PLUGIN_BUNDLER_NAME] = bundlerReport.name; + spanTags[BUILD_PLUGIN_BUNDLER_VERSION] = bundlerReport.version; + }, + // NOTE: This is a bit off for esbuild because of its "trueEnd" implementation. + async asyncTrueEnd() { + if (!options.auth?.apiKey) { + log.info('No auth options, skipping.'); + return; + } + + const ci_provider = getCIProvider(); + // Only run if we're on a supported provider. + if (!SUPPORTED_PROVIDERS.includes(ci_provider)) { + log.info( + `"${ci_provider}" is not a supported provider, skipping spans submission`, + ); + return; + } + + const spansToSubmit = getCustomSpans(ci_provider, context); + + try { + const { errors, warnings } = await sendSpans( + options.auth, + spansToSubmit, + spanTags, + log, + ); + + if (warnings.length > 0) { + log.warn( + `Warnings while submitting spans:\n - ${warnings.join('\n - ')}`, + ); + } + + if (errors.length) { + log.warn(`Error submitting some spans:\n - ${errors.join('\n - ')}`); + } + } catch (error) { + log.warn(`Error submitting spans: ${error}`); + } + }, + }, + ]; +}; diff --git a/packages/plugins/ci-visibility/src/types.ts b/packages/plugins/ci-visibility/src/types.ts new file mode 100644 index 000000000..acb10b606 --- /dev/null +++ b/packages/plugins/ci-visibility/src/types.ts @@ -0,0 +1,100 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Assign, LogTags } from '@dd/core/types'; + +import type { + GIT_COMMIT_AUTHOR_EMAIL, + GIT_COMMIT_AUTHOR_NAME, + GIT_COMMIT_AUTHOR_DATE, + GIT_COMMIT_MESSAGE, + GIT_COMMIT_COMMITTER_DATE, + GIT_COMMIT_COMMITTER_EMAIL, + GIT_COMMIT_COMMITTER_NAME, + CI_ENV_VARS, + CI_NODE_NAME, + CI_NODE_LABELS, + GIT_BASE_REF, + GIT_HEAD_SHA, + GIT_PULL_REQUEST_BASE_BRANCH, + GIT_PULL_REQUEST_BASE_BRANCH_SHA, + SUPPORTED_PROVIDERS, + GIT_TAG, + CI_JOB_NAME, + CI_JOB_URL, + CI_PIPELINE_ID, + CI_PIPELINE_NAME, + CI_PIPELINE_NUMBER, + CI_PIPELINE_URL, + CI_PROVIDER_NAME, + CI_STAGE_NAME, + CI_WORKSPACE_PATH, + GIT_BRANCH, + GIT_REPOSITORY_URL, + GIT_SHA, + BUILD_PLUGIN_VERSION, + BUILD_PLUGIN_ENV, + BUILD_PLUGIN_BUNDLER_NAME, + BUILD_PLUGIN_BUNDLER_VERSION, + BUILD_PLUGIN_SPAN_PREFIX, +} from './constants'; + +export type CiVisibilityOptions = { + disabled?: boolean; +}; + +export interface CustomSpan { + ci_provider: string; + span_id: string; + command: string; + name: string; + start_time: string; + end_time: string; + error_message: string; + exit_code: number; + tags: LogTags; + measures: Partial>; +} + +export type CustomSpanPayload = Assign; + +export type Provider = (typeof SUPPORTED_PROVIDERS)[number]; + +export type SpanTag = + | typeof CI_ENV_VARS + | typeof CI_JOB_NAME + | typeof CI_JOB_URL + | typeof CI_NODE_LABELS + | typeof CI_NODE_NAME + | typeof CI_PIPELINE_ID + | typeof CI_PIPELINE_NAME + | typeof CI_PIPELINE_NUMBER + | typeof CI_PIPELINE_URL + | typeof CI_PROVIDER_NAME + | typeof CI_STAGE_NAME + | typeof CI_WORKSPACE_PATH + | typeof GIT_BASE_REF + | typeof GIT_BRANCH + | typeof GIT_COMMIT_AUTHOR_DATE + | typeof GIT_COMMIT_AUTHOR_EMAIL + | typeof GIT_COMMIT_AUTHOR_NAME + | typeof GIT_COMMIT_COMMITTER_DATE + | typeof GIT_COMMIT_COMMITTER_EMAIL + | typeof GIT_COMMIT_COMMITTER_NAME + | typeof GIT_COMMIT_MESSAGE + | typeof GIT_HEAD_SHA + | typeof GIT_PULL_REQUEST_BASE_BRANCH + | typeof GIT_PULL_REQUEST_BASE_BRANCH_SHA + | typeof GIT_REPOSITORY_URL + | typeof GIT_SHA + | typeof GIT_TAG + | typeof BUILD_PLUGIN_VERSION + | typeof BUILD_PLUGIN_ENV + | typeof BUILD_PLUGIN_BUNDLER_NAME + | typeof BUILD_PLUGIN_BUNDLER_VERSION + | `${typeof BUILD_PLUGIN_SPAN_PREFIX}.${string}`; + +export type SpanTags = Partial>; + +export type CiVisibilityOptionsWithDefaults = Required; diff --git a/packages/plugins/ci-visibility/tsconfig.json b/packages/plugins/ci-visibility/tsconfig.json new file mode 100644 index 000000000..6c1d3065e --- /dev/null +++ b/packages/plugins/ci-visibility/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./", + "outDir": "./dist" + }, + "include": ["**/*"], + "exclude": ["dist", "node_modules"] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 9a8ebf44b..f7f75bdef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1650,6 +1650,18 @@ __metadata: languageName: unknown linkType: soft +"@dd/ci-visibility-plugin@workspace:packages/plugins/ci-visibility": + version: 0.0.0-use.local + resolution: "@dd/ci-visibility-plugin@workspace:packages/plugins/ci-visibility" + dependencies: + "@dd/core": "workspace:*" + "@dd/internal-build-report-plugin": "workspace:*" + chalk: "npm:2.3.1" + p-queue: "npm:6.6.2" + typescript: "npm:5.4.3" + languageName: unknown + linkType: soft + "@dd/core@workspace:*, @dd/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@dd/core@workspace:packages/core" From 59750a65f0efe217975ad2e79d4ca837c2b2dd71 Mon Sep 17 00:00:00 2001 From: Yoann Moinet Date: Fri, 16 May 2025 15:58:18 +0200 Subject: [PATCH 2/3] Automated update following plugin boostrap --- README.md | 24 +++++++++++++++++++ packages/core/src/types.ts | 3 +++ packages/factory/package.json | 1 + packages/factory/src/index.ts | 3 +++ .../published/esbuild-plugin/src/index.ts | 2 ++ packages/published/rollup-plugin/src/index.ts | 2 ++ packages/published/rspack-plugin/src/index.ts | 2 ++ packages/published/vite-plugin/src/index.ts | 2 ++ .../published/webpack-plugin/src/index.ts | 2 ++ yarn.lock | 3 ++- 10 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b265b12d..00b31ffe8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ To interact with Datadog directly from your builds. - [`logLevel`](#loglevel) - [`metadata.name`](#metadataname) - [Features](#features) + - [Ci Visibility](#ci-visibility-----) - [Error Tracking](#error-tracking-----) - [Telemetry](#telemetry-----) - [Contributing](#contributing) @@ -91,6 +92,9 @@ Follow the specific documentation for each bundler: }; customPlugins?: (arg: GetPluginsArg) => UnpluginPlugin[]; logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'none'; + ciVisibility?: { + disabled?: boolean; + }; errorTracking?: { disabled?: boolean; sourcemaps?: { @@ -242,6 +246,26 @@ This is used to identify the build in logs, metrics and spans. ## Features +### Ci Visibility ESBuild Rollup Rspack Vite Webpack + +> Interact with CI Visibility directly from your build system. + +#### [📝 Full documentation ➡️](/packages/plugins/ci-visibility#readme) + +
+ +Configuration + +```typescript +datadogWebpackPlugin({ + ciVisibility?: { + disabled?: boolean, + } +}); +``` + +
+ ### Error Tracking ESBuild Rollup Rspack Vite Webpack > Interact with Error Tracking directly from your build system. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9d45297c1..df5f085dc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,6 +9,8 @@ import type { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMatcher'; /* eslint-disable arca/import-ordering */ // #imports-injection-marker +import type { CiVisibilityOptions } from '@dd/ci-visibility-plugin/types'; +import type * as ciVisibility from '@dd/ci-visibility-plugin'; import type { ErrorTrackingOptions } from '@dd/error-tracking-plugin/types'; import type * as errorTracking from '@dd/error-tracking-plugin'; import type { RumOptions } from '@dd/rum-plugin/types'; @@ -229,6 +231,7 @@ export interface BaseOptions { export interface Options extends BaseOptions { // Each product should have a unique entry. // #types-injection-marker + [ciVisibility.CONFIG_KEY]?: CiVisibilityOptions; [errorTracking.CONFIG_KEY]?: ErrorTrackingOptions; [rum.CONFIG_KEY]?: RumOptions; [telemetry.CONFIG_KEY]?: TelemetryOptions; diff --git a/packages/factory/package.json b/packages/factory/package.json index 29a06ad91..3bfd27a4e 100644 --- a/packages/factory/package.json +++ b/packages/factory/package.json @@ -16,6 +16,7 @@ "./*": "./src/*.ts" }, "dependencies": { + "@dd/ci-visibility-plugin": "workspace:*", "@dd/core": "workspace:*", "@dd/error-tracking-plugin": "workspace:*", "@dd/internal-analytics-plugin": "workspace:*", diff --git a/packages/factory/src/index.ts b/packages/factory/src/index.ts index 4a38e6980..46c9b2b9d 100644 --- a/packages/factory/src/index.ts +++ b/packages/factory/src/index.ts @@ -31,6 +31,7 @@ import { getContext } from './helpers/context'; import { wrapGetPlugins } from './helpers/wrapPlugins'; import { HOST_NAME } from '@dd/core/constants'; // #imports-injection-marker +import * as ciVisibility from '@dd/ci-visibility-plugin'; import * as errorTracking from '@dd/error-tracking-plugin'; import * as rum from '@dd/rum-plugin'; import * as telemetry from '@dd/telemetry-plugin'; @@ -43,6 +44,7 @@ import { getInjectionPlugins } from '@dd/internal-injection-plugin'; import { getTrueEndPlugins } from '@dd/internal-true-end-plugin'; // #imports-injection-marker // #types-export-injection-marker +export type { types as CiVisibilityTypes } from '@dd/ci-visibility-plugin'; export type { types as ErrorTrackingTypes } from '@dd/error-tracking-plugin'; export type { types as RumTypes } from '@dd/rum-plugin'; export type { types as TelemetryTypes } from '@dd/telemetry-plugin'; @@ -107,6 +109,7 @@ export const buildPluginFactory = ({ // Add the customer facing plugins. pluginsToAdd.push( // #configs-injection-marker + ['ci-visibility', ciVisibility.getPlugins], ['error-tracking', errorTracking.getPlugins], ['rum', rum.getPlugins], ['telemetry', telemetry.getPlugins], diff --git a/packages/published/esbuild-plugin/src/index.ts b/packages/published/esbuild-plugin/src/index.ts index 6ff523630..9939d404c 100644 --- a/packages/published/esbuild-plugin/src/index.ts +++ b/packages/published/esbuild-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, @@ -22,6 +23,7 @@ import pkg from '../package.json'; export type EsbuildPluginOptions = Options; export type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, diff --git a/packages/published/rollup-plugin/src/index.ts b/packages/published/rollup-plugin/src/index.ts index 59c46cba7..ae889b6ee 100644 --- a/packages/published/rollup-plugin/src/index.ts +++ b/packages/published/rollup-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, @@ -22,6 +23,7 @@ import pkg from '../package.json'; export type RollupPluginOptions = Options; export type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, diff --git a/packages/published/rspack-plugin/src/index.ts b/packages/published/rspack-plugin/src/index.ts index aec0805e7..2f3deb5de 100644 --- a/packages/published/rspack-plugin/src/index.ts +++ b/packages/published/rspack-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, @@ -22,6 +23,7 @@ import pkg from '../package.json'; export type RspackPluginOptions = Options; export type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, diff --git a/packages/published/vite-plugin/src/index.ts b/packages/published/vite-plugin/src/index.ts index 7ea5cfbf6..2b270e789 100644 --- a/packages/published/vite-plugin/src/index.ts +++ b/packages/published/vite-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, @@ -22,6 +23,7 @@ import pkg from '../package.json'; export type VitePluginOptions = Options; export type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, diff --git a/packages/published/webpack-plugin/src/index.ts b/packages/published/webpack-plugin/src/index.ts index 24a29bbfd..04c55cb94 100644 --- a/packages/published/webpack-plugin/src/index.ts +++ b/packages/published/webpack-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, @@ -22,6 +23,7 @@ import pkg from '../package.json'; export type WebpackPluginOptions = Options; export type { // #types-export-injection-marker + CiVisibilityTypes, ErrorTrackingTypes, RumTypes, TelemetryTypes, diff --git a/yarn.lock b/yarn.lock index f7f75bdef..6827a31d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1650,7 +1650,7 @@ __metadata: languageName: unknown linkType: soft -"@dd/ci-visibility-plugin@workspace:packages/plugins/ci-visibility": +"@dd/ci-visibility-plugin@workspace:*, @dd/ci-visibility-plugin@workspace:packages/plugins/ci-visibility": version: 0.0.0-use.local resolution: "@dd/ci-visibility-plugin@workspace:packages/plugins/ci-visibility" dependencies: @@ -1693,6 +1693,7 @@ __metadata: version: 0.0.0-use.local resolution: "@dd/factory@workspace:packages/factory" dependencies: + "@dd/ci-visibility-plugin": "workspace:*" "@dd/core": "workspace:*" "@dd/error-tracking-plugin": "workspace:*" "@dd/internal-analytics-plugin": "workspace:*" From 426479e48a164e2e98f3a039b7abc348ce58c24e Mon Sep 17 00:00:00 2001 From: Yoann Moinet Date: Fri, 16 May 2025 16:01:46 +0200 Subject: [PATCH 3/3] Add `capitalize` helper --- packages/core/src/helpers/strings.test.ts | 16 ++++++++++++++++ packages/core/src/helpers/strings.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/packages/core/src/helpers/strings.test.ts b/packages/core/src/helpers/strings.test.ts index 110bcf51c..fa6d37f10 100644 --- a/packages/core/src/helpers/strings.test.ts +++ b/packages/core/src/helpers/strings.test.ts @@ -116,4 +116,20 @@ describe('Strings Helpers', () => { expect(filterSensitiveInfoFromRepositoryUrl(input)).toBe(expected); }); }); + + describe('capitalize', () => { + test.each([ + ['hello world', 'Hello World'], + ['hello', 'Hello'], + ['HELLO', 'Hello'], + ['hELLO', 'Hello'], + ['hELLO wORLD', 'Hello World'], + ['hELLO wORLD!', 'Hello World!'], + ['hELLO wORLD! 123', 'Hello World! 123'], + ['', ''], + ])('Should capitalize "%s" => "%s"', async (str, expected) => { + const { capitalize } = await import('@dd/core/helpers/strings'); + expect(capitalize(str)).toBe(expected); + }); + }); }); diff --git a/packages/core/src/helpers/strings.ts b/packages/core/src/helpers/strings.ts index 1af2c4a2b..8f275c3d2 100644 --- a/packages/core/src/helpers/strings.ts +++ b/packages/core/src/helpers/strings.ts @@ -60,5 +60,12 @@ export const filterSensitiveInfoFromRepositoryUrl = (repositoryUrl: string = '') } }; +// Capitalize the first letter of each word in a string. +export const capitalize = (str: string) => + str + .split(' ') + .map((st) => st.charAt(0).toUpperCase() + st.slice(1).toLowerCase()) + .join(' '); + let index = 0; export const getUniqueId = () => `${Date.now()}.${performance.now()}.${++index}`;