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
23 changes: 23 additions & 0 deletions packages/datadog-ci/src/commands/dsyms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ In addition, some optional parameters are available:

* `--max-concurrency` (default: `20`): number of concurrent upload to the API.
* `--dry-run` (default: `false`): it will run the command without the final step of upload. All other checks are performed.
* `--repository-url`: the git repository URL to associate with the uploaded dSYMs. By default, the command will automatically detect the repository URL from your local git configuration.
* `--commit`: the git commit SHA to associate with the uploaded dSYMs. By default, the command will automatically detect the current commit SHA.
* `--disable-git` (default: `false`): prevents the command from collecting git information (repository URL and commit SHA).

#### Source Code Integration

By default, the upload command automatically collects git information (repository URL and commit SHA) from your local git repository and includes it with the uploaded dSYMs. This enables [Source Code Integration][3], which links crash reports to the specific lines of code in your repository.

To manually specify git information:

```bash
datadog-ci dsyms upload ~/Library/Developer/Xcode/DerivedData/ \
--repository-url https://github.com/your-org/your-repo \
--commit abc123def456
```

To disable git information collection:

```bash
datadog-ci dsyms upload ~/Library/Developer/Xcode/DerivedData/ --disable-git
```

#### Bitcode

Expand Down Expand Up @@ -84,6 +105,8 @@ Command summary:
Additional helpful documentation, links, and articles:

- [Learn about iOS Crash Reporting and Error Tracking][1]
- [Learn about Source Code Integration][3]

[1]: https://docs.datadoghq.com/real_user_monitoring/error_tracking/ios/
[2]: https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site
[3]: https://docs.datadoghq.com/integrations/guide/source-code-integration/
156 changes: 155 additions & 1 deletion packages/datadog-ci/src/commands/dsyms/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {buildPath} from '@datadog/datadog-ci-base/helpers/utils'
import {Cli} from 'clipanion'
import upath from 'upath'

import {Dsym} from '../interfaces'
import {CompressedDsym, Dsym, GitData} from '../interfaces'
import {DsymsUploadCommand} from '../upload'
import {createUniqueTmpDirectory, deleteDirectory} from '../utils'

Expand Down Expand Up @@ -407,3 +407,157 @@ describe('execute', () => {
expect(output).toContain('The environment API key ending in _key will be used.')
})
})

describe('git data', () => {
describe('CompressedDsym with git data', () => {
test('Should include git information in metadata payload when gitData is provided', () => {
const dsym: Dsym = {
bundle: '/path/to/test.dSYM',
dwarf: [
{
object: '/path/to/test.dSYM/Contents/Resources/DWARF/test',
uuid: 'ABC123-DEF456-789012',
arch: 'arm64',
},
],
}

const gitData: GitData = {
gitRepositoryURL: 'https://github.com/DataDog/dd-sdk-ios',
gitCommitSha: 'abc123def456789',
}

const compressed = new CompressedDsym('/tmp/test.zip', dsym)
compressed.gitData = gitData

const payload = compressed.asMultipartPayload()

const eventContent = payload.content.get('event')
expect(eventContent).toBeDefined()
expect(eventContent?.type).toBe('string')

const metadata = JSON.parse((eventContent as any).value)
expect(metadata.type).toBe('ios_symbols')
expect(metadata.uuids).toBe('ABC123-DEF456-789012')
expect(metadata.git_repository_url).toBe('https://github.com/DataDog/dd-sdk-ios')
expect(metadata.git_commit_sha).toBe('abc123def456789')
})

test('Should not include git information in metadata payload when gitData is not provided', () => {
const dsym: Dsym = {
bundle: '/path/to/test.dSYM',
dwarf: [
{
object: '/path/to/test.dSYM/Contents/Resources/DWARF/test',
uuid: 'ABC123-DEF456-789012',
arch: 'arm64',
},
],
}

const compressed = new CompressedDsym('/tmp/test.zip', dsym)
const payload = compressed.asMultipartPayload()

const eventContent = payload.content.get('event')
expect(eventContent).toBeDefined()
expect(eventContent?.type).toBe('string')

const metadata = JSON.parse((eventContent as any).value)
expect(metadata.type).toBe('ios_symbols')
expect(metadata.uuids).toBe('ABC123-DEF456-789012')
expect(metadata.git_repository_url).toBeUndefined()
expect(metadata.git_commit_sha).toBeUndefined()
})

test('Should include repository blob in multipart payload when gitRepositoryPayload is provided', () => {
const dsym: Dsym = {
bundle: '/path/to/test.dSYM',
dwarf: [
{
object: '/path/to/test.dSYM/Contents/Resources/DWARF/test',
uuid: 'ABC123-DEF456-789012',
arch: 'arm64',
},
],
}

const repositoryBlob = JSON.stringify({
version: 1,
data: [
{
repository_url: 'https://github.com/DataDog/dd-sdk-ios',
hash: 'abc123def456789',
files: ['src/AppDelegate.swift', 'src/ViewController.swift'],
},
],
})

const gitData: GitData = {
gitRepositoryURL: 'https://github.com/DataDog/dd-sdk-ios',
gitCommitSha: 'abc123def456789',
gitRepositoryPayload: repositoryBlob,
}

const compressed = new CompressedDsym('/tmp/test.zip', dsym)
compressed.gitData = gitData

const payload = compressed.asMultipartPayload()

// Check repository blob is included in multipart payload
const repositoryContent = payload.content.get('repository')
expect(repositoryContent).toBeDefined()
expect(repositoryContent?.type).toBe('string')

expect((repositoryContent as any).options?.contentType).toBe('application/json')
expect((repositoryContent as any).options?.filename).toBe('repository')

const repositoryData = JSON.parse((repositoryContent as any).value)
expect(repositoryData.version).toBe(1)
expect(repositoryData.data).toHaveLength(1)
expect(repositoryData.data[0].repository_url).toBe('https://github.com/DataDog/dd-sdk-ios')
expect(repositoryData.data[0].hash).toBe('abc123def456789')
expect(repositoryData.data[0].files).toEqual(['src/AppDelegate.swift', 'src/ViewController.swift'])

// Check metadata still includes git information
const eventContent = payload.content.get('event')
expect(eventContent).toBeDefined()
const metadata = JSON.parse((eventContent as any).value)
expect(metadata.git_repository_url).toBe('https://github.com/DataDog/dd-sdk-ios')
expect(metadata.git_commit_sha).toBe('abc123def456789')
})

test('Should not include repository blob when gitRepositoryPayload is not provided', () => {
const dsym: Dsym = {
bundle: '/path/to/test.dSYM',
dwarf: [
{
object: '/path/to/test.dSYM/Contents/Resources/DWARF/test',
uuid: 'ABC123-DEF456-789012',
arch: 'arm64',
},
],
}

const gitData: GitData = {
gitRepositoryURL: 'https://github.com/DataDog/dd-sdk-ios',
gitCommitSha: 'abc123def456789',
// No gitRepositoryPayload
}

const compressed = new CompressedDsym('/tmp/test.zip', dsym)
compressed.gitData = gitData

const payload = compressed.asMultipartPayload()

// Repository blob should not be included
const repositoryContent = payload.content.get('repository')
expect(repositoryContent).toBeUndefined()

// But metadata should still include git URL and SHA
const eventContent = payload.content.get('event')
const metadata = JSON.parse((eventContent as any).value)
expect(metadata.git_repository_url).toBe('https://github.com/DataDog/dd-sdk-ios')
expect(metadata.git_commit_sha).toBe('abc123def456789')
})
})
})
33 changes: 29 additions & 4 deletions packages/datadog-ci/src/commands/dsyms/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ export interface DWARF {
arch: string
}

export interface GitData {
gitCommitSha: string
gitRepositoryPayload?: string
gitRepositoryURL: string
}

export class CompressedDsym {
public archivePath: string
public dsym: Dsym
public gitData?: GitData

constructor(archivePath: string, dsym: Dsym) {
this.archivePath = archivePath
Expand All @@ -25,6 +32,17 @@ export class CompressedDsym {
['event', this.getMetadataPayload()],
])

if (this.gitData !== undefined && this.gitData.gitRepositoryPayload !== undefined) {
content.set('repository', {
type: 'string',
options: {
contentType: 'application/json',
filename: 'repository',
},
value: this.gitData.gitRepositoryPayload,
})
}

return {
content,
}
Expand All @@ -33,16 +51,23 @@ export class CompressedDsym {
private getMetadataPayload(): MultipartValue {
const concatUUIDs = this.dsym.dwarf.map((d) => d.uuid).join()

const metadata: {[k: string]: any} = {
type: 'ios_symbols',
uuids: concatUUIDs,
}

if (this.gitData) {
metadata.git_repository_url = this.gitData.gitRepositoryURL
metadata.git_commit_sha = this.gitData.gitCommitSha
}

return {
type: 'string',
options: {
contentType: 'application/json',
filename: 'event',
},
value: JSON.stringify({
type: 'ios_symbols',
uuids: concatUUIDs,
}),
value: JSON.stringify(metadata),
}
}
}
57 changes: 57 additions & 0 deletions packages/datadog-ci/src/commands/dsyms/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {doWithMaxConcurrency} from '@datadog/datadog-ci-base/helpers/concurrency
import {toBoolean} from '@datadog/datadog-ci-base/helpers/env'
import {InvalidConfigurationError} from '@datadog/datadog-ci-base/helpers/errors'
import {enableFips} from '@datadog/datadog-ci-base/helpers/fips'
import {
getRepositoryData,
newSimpleGit,
RepositoryData,
} from '@datadog/datadog-ci-base/helpers/git/format-git-sourcemaps-data'
import {globSync} from '@datadog/datadog-ci-base/helpers/glob'
import {RequestBuilder} from '@datadog/datadog-ci-base/helpers/interfaces'
import {getMetricsLogger, MetricsLogger} from '@datadog/datadog-ci-base/helpers/metrics'
Expand Down Expand Up @@ -65,6 +70,9 @@ export class DsymsUploadCommand extends BaseCommand {
private configPath = Option.String('--config')
private dryRun = Option.Boolean('--dry-run', false)
private maxConcurrency = Option.String('--max-concurrency', '20', {validator: validation.isInteger()})
private repositoryURL = Option.String('--repository-url', {required: false})
private commitSHA = Option.String('--commit', {required: false})
private disableGit = Option.Boolean('--disable-git', false)

private cliVersion = cliVersion
private fips = Option.Boolean('--fips', false)
Expand Down Expand Up @@ -172,8 +180,14 @@ export class DsymsUploadCommand extends BaseCommand {
const dsyms = await this.findDsyms(searchDirectory)

const thinDsyms = await this.processDsyms(dsyms, intermediateDirectory)

const compressedDsyms = await this.compressDsyms(thinDsyms, uploadDirectory)

// Add repository data to all payloads if git is enabled
if (!this.disableGit) {
await this.addRepositoryDataToPayloads(compressedDsyms)
}

const requestBuilder = this.createRequestBuilder()
const uploadFunction = this.createUploadFunction(requestBuilder, metricsLogger, apiKeyValidator)

Expand Down Expand Up @@ -310,6 +324,49 @@ export class DsymsUploadCommand extends BaseCommand {
await promises.copyFile(infoPlistPath, newInfoPlistPath)
}

private async addRepositoryDataToPayloads(payloads: CompressedDsym[]) {
try {
const repositoryData = await getRepositoryData(await newSimpleGit(), this.repositoryURL)
const repositoryPayload = this.getRepositoryPayload(repositoryData)

payloads.forEach((payload) => {
payload.gitData = {
gitCommitSha: this.commitSHA || repositoryData.hash,
gitRepositoryPayload: repositoryPayload,
gitRepositoryURL: this.repositoryURL || repositoryData.remote,
}
})
} catch (error) {
// Log warning but don't fail the upload
this.context.stdout.write(`Warning: Failed to collect git information: ${error}\n`)
}
}

private getRepositoryPayload = (repositoryData: RepositoryData): string | undefined => {
try {
// Get ALL tracked files (no filtering needed for dSYMs unlike sourcemaps)
const files = repositoryData.trackedFilesMatcher.rawTrackedFilesList()

if (files) {
return JSON.stringify({
data: [
{
files,
hash: repositoryData.hash,
repository_url: repositoryData.remote,
},
],
// Make sure to update the version if the format of the JSON payloads changes in any way.
version: 1,
})
}
} catch (error) {
this.context.stdout.write(`Warning: Failed to create repository payload: ${error}\n`)
}

return undefined
}

private async compressDsyms(dsyms: Dsym[], output: string): Promise<CompressedDsym[]> {
await promises.mkdir(output, {recursive: true})

Expand Down