From 41d082941e9cbd8580aa509182cc131be97a79c4 Mon Sep 17 00:00:00 2001 From: Bastian Krol Date: Mon, 13 May 2024 14:51:50 +0200 Subject: [PATCH] feat: derive a fallback for service.name if not set by finding the application's main package.json file and using its name and version attribute. --- package-lock.json | 21 +- package.json | 1 + .../index_test.ts | 2 +- .../index.ts | 59 +++ .../index_test.ts | 129 ++++++ .../packageJsonUtil.ts | 173 ++++++++ .../packageJsonUtil_test.ts | 373 ++++++++++++++++++ src/index.ts | 2 + test/apps/express-typescript/package.json | 1 - test/apps/express-typescript/tsconfig.json | 1 + test/integration/ChildProcessWrapper.ts | 33 +- test/integration/test.ts | 33 +- test/util/expectAttribute.ts | 11 +- 13 files changed, 822 insertions(+), 17 deletions(-) create mode 100644 src/detectors/node/opentelemetry-resource-detector-service-name-fallback/index.ts create mode 100644 src/detectors/node/opentelemetry-resource-detector-service-name-fallback/index_test.ts create mode 100644 src/detectors/node/opentelemetry-resource-detector-service-name-fallback/packageJsonUtil.ts create mode 100644 src/detectors/node/opentelemetry-resource-detector-service-name-fallback/packageJsonUtil_test.ts diff --git a/package-lock.json b/package-lock.json index e415f48..81cd2c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "sinon": "^17.0.1", "ts-node": "^10.9.2", "ts-proto": "^1.172.0", + "type-fest": "^4.18.2", "typescript": "^5.4.5", "typescript-eslint": "^7.7.1" } @@ -4383,6 +4384,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -6477,12 +6490,12 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.2.tgz", + "integrity": "sha512-+suCYpfJLAe4OXS6+PPXjW3urOS4IoP9waSiLuXfLgqZODKw/aWwASvzqE886wA0kQgGy0mIWyhd87VpqIy6Xg==", "dev": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index c3e052a..7217054 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "sinon": "^17.0.1", "ts-node": "^10.9.2", "ts-proto": "^1.172.0", + "type-fest": "^4.18.2", "typescript": "^5.4.5", "typescript-eslint": "^7.7.1" } diff --git a/src/detectors/node/opentelemetry-resource-detector-kubernetes-pod/index_test.ts b/src/detectors/node/opentelemetry-resource-detector-kubernetes-pod/index_test.ts index 665f42d..c81ba64 100644 --- a/src/detectors/node/opentelemetry-resource-detector-kubernetes-pod/index_test.ts +++ b/src/detectors/node/opentelemetry-resource-detector-kubernetes-pod/index_test.ts @@ -96,7 +96,7 @@ const procSelfMountInfoContentWithPodUid2 = `2317 1432 0:473 / / rw,relatime mas 1456 2318 0:476 /null /proc/sched_debug rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 1457 2322 0:480 / /sys/firmware ro,relatime - tmpfs tmpfs ro`; -describe('podUidDetector', () => { +describe('pod ui detection', () => { const sandbox = sinon.createSandbox(); let readFileStub: sinon.SinonStub; let podUidDetector: PodUidDetector; diff --git a/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/index.ts b/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/index.ts new file mode 100644 index 0000000..a3212bb --- /dev/null +++ b/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/index.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { DetectorSync, Resource } from '@opentelemetry/resources'; +import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; + +import { readPackageJson } from './packageJsonUtil'; + +export default class ServiceNameFallbackDetector implements DetectorSync { + detect(): Resource { + return new Resource({}, this.detectServiceNameFallback()); + } + + private async detectServiceNameFallback(): Promise { + if ( + hasOptedOutOfServiceNameFallbackDetection() || + hasOTelServiceNameSet() || + hasServiceNameSetViaResourceAttributesThing() + ) { + return {}; + } + + const packageJson = await readPackageJson(); + if (!packageJson) { + return {}; + } + return { [SEMRESATTRS_SERVICE_NAME]: `${packageJson.name}@${packageJson.version}` }; + } +} + +function hasOptedOutOfServiceNameFallbackDetection() { + const automaticServiceName = process.env.DASH0_AUTOMATIC_SERVICE_NAME; + return automaticServiceName && automaticServiceName.trim().toLowerCase() === 'false'; +} + +function hasOTelServiceNameSet() { + const otelServiceName = process.env.OTEL_SERVICE_NAME; + return otelServiceName && otelServiceName.trim() !== ''; +} + +function hasServiceNameSetViaResourceAttributesThing() { + const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; + if (otelResourceAttributes) { + const rawAttributes: string[] = otelResourceAttributes.split(','); + for (const rawAttribute of rawAttributes) { + const keyValuePair: string[] = rawAttribute.split('='); + if (keyValuePair.length !== 2) { + continue; + } + const [key, value] = keyValuePair; + if (key.trim() === SEMRESATTRS_SERVICE_NAME) { + if (value != null && value.trim().split(/^"|"$/).join('').trim().length > 0) { + return true; + } + } + } + } + return false; +} diff --git a/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/index_test.ts b/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/index_test.ts new file mode 100644 index 0000000..6f33b1e --- /dev/null +++ b/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/index_test.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Resource } from '@opentelemetry/resources'; +import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { expect } from 'chai'; +import Sinon from 'sinon'; +import sinon from 'sinon'; + +import ServiceNameFallbackDetector from './index'; +import * as packageJsonUtil from './packageJsonUtil'; + +const packageJson = { + name: '@example/app-under-test', + version: '2.13.47', + description: 'a Node.js application', + main: 'src/index.js', +}; + +const envVarNames = [ + // + 'DASH0_AUTOMATIC_SERVICE_NAME', + 'OTEL_SERVICE_NAME', + 'OTEL_RESOURCE_ATTRIBUTES', +]; + +interface Dict { + [key: string]: T | undefined; +} + +describe('service name fallback', () => { + const sandbox = sinon.createSandbox(); + let readPackageJsonStub: Sinon.SinonStub; + let serviceNameFallback: ServiceNameFallbackDetector; + + const originalEnvVarValues: Dict = {}; + + before(() => { + envVarNames.forEach(envVarName => { + originalEnvVarValues[envVarName] = process.env[envVarName]; + }); + }); + + beforeEach(() => { + readPackageJsonStub = sandbox.stub(packageJsonUtil, 'readPackageJson'); + serviceNameFallback = new ServiceNameFallbackDetector(); + }); + + afterEach(() => { + sandbox.restore(); + envVarNames.forEach(envVarName => { + if (originalEnvVarValues[envVarName] === undefined) { + delete process.env[envVarName]; + } else { + process.env[envVarName] = originalEnvVarValues[envVarName]; + } + }); + }); + + it('sets a service name based on package.json attributes', async () => { + givenAValidPackageJsonFile(); + const result = serviceNameFallback.detect(); + const attributes = await waitForAsyncDetection(result); + expect(attributes).to.have.property(SEMRESATTRS_SERVICE_NAME, '@example/app-under-test@2.13.47'); + }); + + it('does not set a service name if DASH0_AUTOMATIC_SERVICE_NAME is false', async () => { + givenAValidPackageJsonFile(); + process.env.DASH0_AUTOMATIC_SERVICE_NAME = 'false'; + const result = serviceNameFallback.detect(); + const attributes = await waitForAsyncDetection(result); + expect(attributes).to.be.empty; + }); + + it('does not set a service name if OTEL_SERVICE_NAME is set', async () => { + givenAValidPackageJsonFile(); + process.env.OTEL_SERVICE_NAME = 'already-set'; + const result = serviceNameFallback.detect(); + const attributes = await waitForAsyncDetection(result); + expect(attributes).to.be.empty; + }); + + it('sets a service name if OTEL_SERVICE_NAME is set to an empty string', async () => { + givenAValidPackageJsonFile(); + process.env.OTEL_SERVICE_NAME = ' '; + const result = serviceNameFallback.detect(); + const attributes = await waitForAsyncDetection(result); + expect(attributes).to.have.property(SEMRESATTRS_SERVICE_NAME, '@example/app-under-test@2.13.47'); + }); + + it('does not set a service name if OTEL_RESOURCE_ATTRIBUTES has the service.name key', async () => { + givenAValidPackageJsonFile(); + process.env.OTEL_RESOURCE_ATTRIBUTES = 'key1=value,service.name=already-set,key2=valu,key2=valuee'; + const result = serviceNameFallback.detect(); + const attributes = await waitForAsyncDetection(result); + expect(attributes).to.be.empty; + }); + + it('sets a service name if OTEL_RESOURCE_ATTRIBUTES is set but does not have the service.name key', async () => { + givenAValidPackageJsonFile(); + process.env.OTEL_RESOURCE_ATTRIBUTES = 'key1=value,key2=value'; + const result = serviceNameFallback.detect(); + const attributes = await waitForAsyncDetection(result); + expect(attributes).to.have.property(SEMRESATTRS_SERVICE_NAME, '@example/app-under-test@2.13.47'); + }); + + it('does not set a service name if no package.json can be found', async () => { + givenThereIsNoPackageJsonFile(); + const result = serviceNameFallback.detect(); + const attributes = await waitForAsyncDetection(result); + expect(attributes).to.be.empty; + }); + + function givenThereIsNoPackageJsonFile() { + readPackageJsonStub.returns(null); + } + + function givenAValidPackageJsonFile() { + readPackageJsonStub.returns(null).returns(packageJson); + } + + async function waitForAsyncDetection(result: Resource) { + expect(result).to.exist; + expect(result.asyncAttributesPending).to.be.true; + // @ts-expect-error required + await result.waitForAsyncAttributes(); + return result.attributes; + } +}); diff --git a/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/packageJsonUtil.ts b/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/packageJsonUtil.ts new file mode 100644 index 0000000..59b9e55 --- /dev/null +++ b/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/packageJsonUtil.ts @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { PackageJson } from 'type-fest'; + +/** + * We have not tried to compute this specific value yet. + */ +export type Unknown = { + state: 'not-attempted'; +}; + +/** + * We have already tried to compute this specific value but failed. There is no reason to try it again. + */ +export type Failed = { + state: 'attempt-has-failed'; +}; + +/** + * We have already tried to compute this specific value and succeeded. + */ +export type Cached = { + state: 'value-has-been-cached'; + value: T; +}; + +export type CachedValue = Unknown | Failed | Cached; + +export const unknown: Unknown = { + state: 'not-attempted', +}; + +let cachedPackageJson: CachedValue = unknown; +let attemptInProgress: Promise | null = null; + +export async function readPackageJson(): Promise { + if (attemptInProgress) { + return attemptInProgress; + } + switch (cachedPackageJson.state) { + case 'not-attempted': + attemptInProgress = findAndParsePackageJsonFromEntrypoint(); + // eslint-disable-next-line no-case-declarations + const parsedPackageJson = await attemptInProgress; + if (parsedPackageJson != null) { + cachedPackageJson = { state: 'value-has-been-cached', value: parsedPackageJson }; + } else { + cachedPackageJson = { state: 'attempt-has-failed' }; + } + attemptInProgress = null; + return parsedPackageJson; + case 'attempt-has-failed': + return null; + case 'value-has-been-cached': + return cachedPackageJson.value; + default: + throw new Error(`Unknown cache state: ${JSON.stringify(cachedPackageJson)}`); + } +} + +async function findAndParsePackageJsonFromEntrypoint(): Promise { + const entrypoint = process.argv[1]; + let entrypointStat; + try { + entrypointStat = await stat(entrypoint); + } catch (e) { + return null; + } + if (entrypointStat.isDirectory()) { + return checkDirectoryOrAncestor(entrypoint); + } else { + return checkDirectoryOrAncestor(path.dirname(entrypoint)); + } +} + +async function checkDirectoryOrAncestor(directory: string): Promise { + const packageJsonCandidate = path.join(directory, 'package.json'); + let packageJsonStat; + try { + packageJsonStat = await stat(packageJsonCandidate); + } catch (e) { + if (isNoEntityError(e)) { + return traverseToParent(directory); + } else { + return null; + } + } + + if (!packageJsonStat.isFile()) { + return traverseToParent(directory); + } + + const insideNodeModulesFolder = directory.includes('node_modules'); + if (insideNodeModulesFolder) { + // For deployment scenarios where the app is published to a registry and installed from there to the target + // environment, we just go with the first package.json file we find, without checking for a sibling node_modules + // folder. Since the app is installed in a node_modules folder, the app directory itself might not have a + // node_modules folder due to deduplication. + return readAndParse(directory, packageJsonCandidate); + } else { + return checkForSiblingNodeModulesFolder(directory, packageJsonCandidate); + } +} + +async function checkForSiblingNodeModulesFolder( + directory: string, + packageJsonCandidate: string, +): Promise { + const nodeModulesCandidate = path.join(directory, 'node_modules'); + let nodeModulesStat; + try { + nodeModulesStat = await stat(nodeModulesCandidate); + } catch (e) { + if (isNoEntityError(e)) { + return traverseToParent(directory); + } + return null; + } + + if (nodeModulesStat.isDirectory()) { + return readAndParse(directory, packageJsonCandidate); + } else { + return traverseToParent(directory); + } +} + +async function traverseToParent(directory: string): Promise { + const parentDirectory = path.join(directory, '..'); + if (directory === parentDirectory) { + return null; + } + + return checkDirectoryOrAncestor(parentDirectory); +} + +async function readAndParse(directory: string, packageJsonPath: string): Promise { + let packageJsonContent; + try { + packageJsonContent = await readFile(packageJsonPath, { encoding: 'utf8' }); + } catch (e) { + // package.json candidate may not be readable (no permissions) + return traverseToParent(directory); + } + return parse(directory, packageJsonContent); +} + +async function parse(directory: string, packageJsonContent: string): Promise { + try { + return JSON.parse(packageJsonContent); + } catch (e) { + return traverseToParent(directory); + } +} + +function isNoEntityError(error: any): boolean { + return isErrorCode(error, 'ENOENT'); +} + +function isErrorCode(error: any, code: string): error is NodeJS.ErrnoException { + return isError(error) && error.code === code; +} + +function isError(error: any): error is NodeJS.ErrnoException { + return error instanceof Error; +} + +export function _resetOnlyForTesting() { + cachedPackageJson = unknown; + attemptInProgress = null; +} diff --git a/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/packageJsonUtil_test.ts b/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/packageJsonUtil_test.ts new file mode 100644 index 0000000..b9d0c74 --- /dev/null +++ b/src/detectors/node/opentelemetry-resource-detector-service-name-fallback/packageJsonUtil_test.ts @@ -0,0 +1,373 @@ +// SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from 'chai'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import sinon from 'sinon'; +import * as packageJsonUtil from './packageJsonUtil'; + +const packageJsonContent = `{ + "name": "@example/app-under-test", + "version": "2.13.47", + "description": "a Node.js application", + "main": "src/index.js" +}`; + +const theWrongPackageJsonContent = `{ + "name": "the wrong package.json", +}`; + +const unparseablePackageJsonContent = `{ + "name": "@example/app-under-test", + "version": "2.13.47" + "cannot be parsed" +}`; + +const aDirectory = { + isDirectory: () => true, + isFile: () => false, +}; +const aFile = { + isDirectory: () => false, + isFile: () => true, +}; +const doesNotExist = 'ENOENT'; +const unreadableFile = 'EACCES'; + +function newENOENT(currentPath: string): NodeJS.ErrnoException { + const error: NodeJS.ErrnoException = new Error(`ENOENT: no such file or directory, open '${currentPath}'`); + error.code = 'ENOENT'; + return error; +} + +function newEACCESS(currentPath: string) { + const error: NodeJS.ErrnoException = new Error(`EACCES: permission denied, open '${currentPath}'`); + error.code = 'EACCES'; + return error; +} + +describe('package json util', () => { + const sandbox = sinon.createSandbox(); + + let readFileStub: sinon.SinonStub; + let statStub: sinon.SinonStub; + let originalArgv1: string; + + before(async () => { + originalArgv1 = process.argv[1]; + }); + + beforeEach(() => { + readFileStub = sandbox.stub(fs, 'readFile'); + statStub = sandbox.stub(fs, 'stat'); + + // for paths not handled in createDirectoryStructure, always respond with ENOENT + statStub.throws(newENOENT('')); + }); + + afterEach(() => { + sandbox.restore(); + process.argv[1] = originalArgv1; + packageJsonUtil._resetOnlyForTesting(); + }); + + it('finds the package.json file if argv[1] is a file in the same directory', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'app.js': aFile, + 'package.json': packageJsonContent, + node_modules: aDirectory, + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'app.js'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.exist; + expect(packageJson?.name).to.equal('@example/app-under-test'); + }); + + it('disregards a package.json file if there is no node_modules folder', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'app.js': aFile, + 'package.json': packageJsonContent, + // no sibling node_modules folder + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'app.js'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.not.exist; + }); + + it('finds the package.json file if argv[1] is a directory with package.json and node_modules in it', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'package.json': packageJsonContent, + node_modules: aDirectory, + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.exist; + expect(packageJson?.name).to.equal('@example/app-under-test'); + }); + + it('disregards the package.json file if argv[1] is a directory with package.json but no node_modules folder in it', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'package.json': packageJsonContent, + // no sibling node_modules folder + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.not.exist; + }); + + it('returns null if there is no package.json in the directory tree', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'app.js': aFile, + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'app.js'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.not.exist; + }); + + it('can cope with a non-parseable package.json files', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'app.js': aFile, + // packageJsonUtil will attempt to parse this file, recover, and continue traversing the directory tree up + // after that. + 'package.json': unparseablePackageJsonContent, + node_modules: aDirectory, + }, + }, + }, + 'package.json': packageJsonContent, + node_modules: aDirectory, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'app.js'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.exist; + expect(packageJson?.name).to.equal('@example/app-under-test'); + }); + + it('can cope with a non-readable package.json files', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'app.js': aFile, + // packageJsonUtil will attempt to parse this file, recover, and continue traversing the directory tree up + // after that. + 'package.json': unreadableFile, + node_modules: aDirectory, + }, + }, + }, + 'package.json': packageJsonContent, + node_modules: aDirectory, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'app.js'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.exist; + expect(packageJson?.name).to.equal('@example/app-under-test'); + }); + + it('caches the parsed package.json file', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'app.js': aFile, + 'package.json': packageJsonContent, + node_modules: aDirectory, + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'app.js'); + + const packageJson1 = await packageJsonUtil.readPackageJson(); + const packageJson2 = await packageJsonUtil.readPackageJson(); + expect(packageJson1).to.exist; + expect(packageJson2).to.exist; + expect(packageJson1?.name).to.equal('@example/app-under-test'); + expect(packageJson2?.name).to.equal('@example/app-under-test'); + + expect(statStub.withArgs(process.argv[1]).callCount).to.equal(1); + expect(readFileStub.callCount).to.equal(1); + }); + + it('caches the fact that no package.json file has been found', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'app.js': aFile, + 'package.json': packageJsonContent, + // no sibling node_modules folder + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'app.js'); + + const packageJson1 = await packageJsonUtil.readPackageJson(); + const packageJson2 = await packageJsonUtil.readPackageJson(); + expect(packageJson1).to.not.exist; + expect(packageJson2).to.not.exist; + + expect(statStub.withArgs(process.argv[1]).callCount).to.equal(1); + }); + + it('queues concurrent attempts and resolves all of them with the same promise', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + 'app.js': aFile, + 'package.json': packageJsonContent, + node_modules: aDirectory, + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'app.js'); + + const packageJson1Promise = packageJsonUtil.readPackageJson(); + const packageJson2Promise = packageJsonUtil.readPackageJson(); + const [packageJson1, packageJson2] = await Promise.all([packageJson1Promise, packageJson2Promise]); + expect(packageJson1).to.exist; + expect(packageJson2).to.exist; + expect(packageJson1?.name).to.equal('@example/app-under-test'); + expect(packageJson2?.name).to.equal('@example/app-under-test'); + + expect(statStub.withArgs(process.argv[1]).callCount).to.equal(1); + expect(readFileStub.callCount).to.equal(1); + }); + + it('finds the package.json file if argv[1] in a nested directory', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + src: { + nested: { + folder: { + 'app.js': aFile, + 'package.json': theWrongPackageJsonContent, + // no sibling node_modules folder + }, + 'package.json': theWrongPackageJsonContent, + // no sibling node_modules folder + }, + }, + // This is the viable candidate, the other package.json files will be disregarded. + 'package.json': packageJsonContent, + node_modules: aDirectory, + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'src', 'nested', 'folder', 'app.js'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.exist; + expect(packageJson?.name).to.equal('@example/app-under-test'); + }); + + it('finds the package.json file if the app is installed into node_modules', async () => { + createDirectoryStructure({ + path: { + to: { + app: { + node_modules: { + '@scope': { + app: { + 'app.js': aFile, + 'package.json': packageJsonContent, + // no sibling node_modules folder, but for apps installed into node_modules that is fine + }, + 'package.json': theWrongPackageJsonContent, + node_modules: aDirectory, + }, + 'package.json': theWrongPackageJsonContent, + node_modules: aDirectory, + }, + 'package.json': theWrongPackageJsonContent, + }, + }, + }, + }); + process.argv[1] = path.join(__dirname, 'path', 'to', 'app', 'node_modules', '@scope', 'app'); + + const packageJson = await packageJsonUtil.readPackageJson(); + expect(packageJson).to.exist; + expect(packageJson?.name).to.equal('@example/app-under-test'); + }); + + type Structure = { + [key: string]: Structure | typeof aDirectory | typeof aFile | typeof doesNotExist | string; + }; + + function createDirectoryStructure(structure: Structure) { + createStructure(__dirname, structure); + } + + function createStructure(currentPath: string, structure: Structure) { + Object.keys(structure).forEach(name => { + const dirEntry = structure[name]; + const p = path.join(currentPath, name); + if (dirEntry === doesNotExist) { + statStub.withArgs(p).throws(newENOENT(currentPath)); + } else if (dirEntry === unreadableFile) { + statStub.withArgs(p).returns(aFile); + readFileStub.withArgs(p).throws(newEACCESS(currentPath)); + } else if (typeof dirEntry === 'string') { + statStub.withArgs(p).returns(aFile); + readFileStub.withArgs(p).returns(dirEntry); + } else if (dirEntry === aFile) { + statStub.withArgs(p).returns(aFile); + } else if (dirEntry === aDirectory) { + statStub.withArgs(p).returns(aDirectory); + } else { + statStub.withArgs(p).returns(aDirectory); + // @ts-expect-error this is fine + createStructure(p, dirEntry); + } + }); + } +}); diff --git a/src/index.ts b/src/index.ts index 22f085a..deb2c9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { NodeSDK, NodeSDKConfiguration } from '@opentelemetry/sdk-node'; import { version } from '../package.json'; import PodUidDetector from './detectors/node/opentelemetry-resource-detector-kubernetes-pod'; +import ServiceNameFallbackDetector from './detectors/node/opentelemetry-resource-detector-service-name-fallback'; import { getResourceDetectorsFromEnv } from './util/sdkUtil'; if (process.env.DASH0_DEBUG) { @@ -57,6 +58,7 @@ if (process.env.OTEL_NODE_RESOURCE_DETECTORS != null) { detectors = [envDetector, processDetector, hostDetector]; } detectors.push(new PodUidDetector()); +detectors.push(new ServiceNameFallbackDetector()); configuration.resourceDetectors = detectors; const sdk = new NodeSDK(configuration); diff --git a/test/apps/express-typescript/package.json b/test/apps/express-typescript/package.json index 8471f07..83b99fd 100644 --- a/test/apps/express-typescript/package.json +++ b/test/apps/express-typescript/package.json @@ -4,7 +4,6 @@ "description": "", "main": "app.ts", "scripts": { - "start-instrumented": "ts-node --require ../../../dist app.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Bastian Krol ", diff --git a/test/apps/express-typescript/tsconfig.json b/test/apps/express-typescript/tsconfig.json index f141bf8..2b98ecb 100644 --- a/test/apps/express-typescript/tsconfig.json +++ b/test/apps/express-typescript/tsconfig.json @@ -4,6 +4,7 @@ "esModuleInterop": true, "module": "commonjs", "moduleResolution": "node", + "resolveJsonModule": true, "skipLibCheck": true, "strict": true, "target": "ES2022" diff --git a/test/integration/ChildProcessWrapper.ts b/test/integration/ChildProcessWrapper.ts index 8cbc6e0..4a5ac6a 100644 --- a/test/integration/ChildProcessWrapper.ts +++ b/test/integration/ChildProcessWrapper.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. // SPDX-License-Identifier: Apache-2.0 +import fs from 'node:fs/promises'; +import path from 'node:path'; import { ChildProcess, fork, ForkOptions } from 'node:child_process'; import EventEmitter from 'node:events'; import waitUntil, { RetryOptions } from '../util/waitUntil'; @@ -16,6 +18,10 @@ export interface ChildProcessWrapperOptions { waitForReadyRetryOptions?: RetryOptions; } +const repoRoot = path.join(__dirname, '..', '..'); +const distroPath = path.join(repoRoot, 'src'); +const emulateKubernetesPath = path.join(repoRoot, 'test', 'integration', 'emulateKubernetes'); + export default class ChildProcessWrapper { private childProcess?: ChildProcess; private ready: boolean; @@ -34,30 +40,45 @@ export default class ChildProcessWrapper { } async start() { - const forkOptions = this.createForkOptions(); - this.childProcess = fork(this.options.path, this.options.args ?? [], forkOptions); + const { modulePath, forkOptions } = await this.createForkOptions(); + this.childProcess = fork(modulePath, this.options.args ?? [], forkOptions); this.listenToIpcMessages(); this.echoOutputStreams(); await this.waitUntilReady(); } - private createForkOptions() { + private async createForkOptions(): Promise<{ modulePath: string; forkOptions: ForkOptions }> { + let cwd; + let modulePath; + const stat = await fs.stat(this.options.path); + if (stat.isDirectory()) { + // provided path is a directory, use that directory as working directory and do fork "node ." + cwd = path.resolve(repoRoot, this.options.path); + modulePath = '.'; + } else { + // provided path is a file, use that file's parent directory as working directory and fork "node filename" + cwd = path.resolve(repoRoot, path.dirname(this.options.path)); + modulePath = path.basename(this.options.path); + } + const forkOptions: ForkOptions = { + cwd, stdio: ['inherit', 'pipe', 'pipe', 'ipc'], }; if (this.options.useTsNode) { this.addExecArgvs(forkOptions, '--require', 'ts-node/register'); } if (this.options.emulateKubernetesPodUid) { - this.addExecArgvs(forkOptions, '--require', './test/integration/emulateKubernetes'); + // Note: Kubernetes file system stubbing needs to come before --require distroPath. + this.addExecArgvs(forkOptions, '--require', emulateKubernetesPath); } if (this.options.useDistro) { - this.addExecArgvs(forkOptions, '--require', './src'); + this.addExecArgvs(forkOptions, '--require', distroPath); } if (this.options.env) { forkOptions.env = this.options.env; } - return forkOptions; + return { modulePath, forkOptions }; } private addExecArgvs(forkOptions: ForkOptions, ...execArgvs: string[]) { diff --git a/test/integration/test.ts b/test/integration/test.ts index 2374bca..93c595c 100644 --- a/test/integration/test.ts +++ b/test/integration/test.ts @@ -33,7 +33,7 @@ describe('attach', () => { await appUnderTest.stop(); }); - it('should attach via --require and collect capture spans', async () => { + it('should attach via --require and capture spans', async () => { await waitUntil(async () => { const telemetry = await waitForTelemetry(); expectMatchingSpan( @@ -82,6 +82,37 @@ describe('attach', () => { }); }); + describe('service name fallback', () => { + let appUnderTest: ChildProcessWrapper; + + before(async () => { + const appConfiguration = defaultAppConfiguration(); + appUnderTest = new ChildProcessWrapper(appConfiguration); + await appUnderTest.start(); + }); + + after(async () => { + await appUnderTest.stop(); + }); + + it('should attach via --require and derive a service name from the package.json file ', async () => { + await waitUntil(async () => { + const telemetry = await waitForTelemetry(); + expectMatchingSpan( + telemetry.traces, + [ + resource => + expectResourceAttribute(resource, 'service.name', 'dash0-app-under-test-express-typescript@1.0.0'), + ], + [ + span => expect(span.kind).to.equal(SpanKind.SERVER), + span => expectSpanAttribute(span, 'http.route', '/ohai'), + ], + ); + }); + }); + }); + function defaultAppConfiguration(): ChildProcessWrapperOptions { return { path: 'test/apps/express-typescript', diff --git a/test/util/expectAttribute.ts b/test/util/expectAttribute.ts index 009c5b7..f914940 100644 --- a/test/util/expectAttribute.ts +++ b/test/util/expectAttribute.ts @@ -6,6 +6,8 @@ import { KeyValue } from '../collector/types/opentelemetry/proto/common/v1/commo import { Resource } from '../collector/types/opentelemetry/proto/resource/v1/resource'; import { Span } from '../collector/types/opentelemetry/proto/trace/v1/trace'; +const { fail } = expect; + interface WithAttributes { attributes: KeyValue[]; } @@ -24,10 +26,11 @@ export function expectAttribute(object: WithAttributes, key: string, expectedVal found = true; } }); - expect(found).to.equal( - true, - `Expected ${label} to have attribute ${key} with value ${expectedValue}, but no such attribute exists on the ${label}.`, - ); + if (!found) { + fail( + `Expected ${label} to have attribute ${key} with value ${expectedValue}, but no such attribute exists on the ${label}.`, + ); + } } export function expectResourceAttribute(resource: Resource, key: string, expectedValue: any) {