From 5b20fe8db4ae2022e0880a4da2173a6222960edf Mon Sep 17 00:00:00 2001 From: Rowan Manning <138944+rowanmanning@users.noreply.github.com> Date: Mon, 10 Jun 2024 13:23:59 +0100 Subject: [PATCH 1/4] feat: pull the release version from package.json This makes our calculation of system version a lot more solid. This is going to be required for OpenTelemetry metrics, because any metric sent without a version will end up in the Dead Letter metrics. Because of this I want to give users of Reliability Kit more of a guarantee that their metrics will arrive. --- packages/app-info/lib/index.js | 65 +++++++------------ packages/app-info/test/unit/lib/index.spec.js | 49 +++++++++++++- 2 files changed, 71 insertions(+), 43 deletions(-) diff --git a/packages/app-info/lib/index.js b/packages/app-info/lib/index.js index 4f262e37..aea2654c 100644 --- a/packages/app-info/lib/index.js +++ b/packages/app-info/lib/index.js @@ -6,42 +6,28 @@ const path = require('node:path'); // - Heroku: https://devcenter.heroku.com/articles/dyno-metadata // - Lambda: https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html -/** - * Get the application system code from a package.json file. - * - * @param {string} directoryPath - * The directory to look for a package.json file in. - * @returns {(string | null)} - * Returns a system code if one is found in `process.env`. - */ -function getSystemCodeFromPackage(directoryPath) { - try { - const manifest = require(path.join(directoryPath, 'package.json')); - return typeof manifest?.name === 'string' - ? normalizePackageName(manifest.name) - : null; - } catch (error) {} - return null; -} - -/** - * Normalize the name property of a package.json file. - * - * @param {string} name - * The name to normalize. - * @returns {string} - * Returns a normalized copy of the package name. - */ -function normalizePackageName(name) { - // Remove a prefix of "ft-", this is a hangover and we have plenty of - // apps which use this prefix but their system code does not include - // it. E.g. MyFT API has a system code of "next-myft-api", but a - // package.json `name` field of "ft-next-myft-api" - // - https://biz-ops.in.ft.com/System/next-myft-api - // - https://github.com/Financial-Times/next-myft-api/blob/main/package.json - // - return name.replace(/^ft-/, ''); -} +/** @type {null | any} */ +let manifest = null; +try { + manifest = require(path.join(process.cwd(), 'package.json')); +} catch (error) {} + +/** @type {string | null} */ +const manifestName = + typeof manifest?.name === 'string' + ? // Remove a prefix of "ft-", this is a hangover and we have plenty of + // apps which use this prefix but their system code does not include + // it. E.g. MyFT API has a system code of "next-myft-api", but a + // package.json `name` field of "ft-next-myft-api" + // - https://biz-ops.in.ft.com/System/next-myft-api + // - https://github.com/Financial-Times/next-myft-api/blob/main/package.json + // + manifest.name.replace(/^ft-/, '') + : null; + +/** @type {string | null} */ +const manifestVersion = + typeof manifest?.version === 'string' ? manifest.version : null; /** * Extract the process type from a Heroku dyno name. @@ -55,9 +41,6 @@ function normalizeHerokuProcessType(dyno) { return dyno.split('.')[0]; } -const systemCode = - process.env.SYSTEM_CODE || getSystemCodeFromPackage(process.cwd()) || null; - const processType = process.env.AWS_LAMBDA_FUNCTION_NAME || (process.env.DYNO && normalizeHerokuProcessType(process.env.DYNO)) || @@ -124,7 +107,7 @@ exports.releaseDate = process.env.HEROKU_RELEASE_CREATED_AT || null; exports.releaseVersion = process.env.HEROKU_RELEASE_VERSION || process.env.AWS_LAMBDA_FUNCTION_VERSION || - null; + manifestVersion; /** * The application system code. @@ -132,7 +115,7 @@ exports.releaseVersion = * @readonly * @type {string | null} */ -exports.systemCode = systemCode; +exports.systemCode = process.env.SYSTEM_CODE || manifestName; /** * The dyno process type. diff --git a/packages/app-info/test/unit/lib/index.spec.js b/packages/app-info/test/unit/lib/index.spec.js index 153b3a04..903065d8 100644 --- a/packages/app-info/test/unit/lib/index.spec.js +++ b/packages/app-info/test/unit/lib/index.spec.js @@ -155,15 +155,60 @@ describe('@dotcom-reliability-kit/app-info', () => { }); }); - describe('when neither environment variable is defined', () => { + describe('when neither environment variable is defined but a package.json exists', () => { beforeEach(() => { jest.resetModules(); + jest.mock( + '/mock-cwd/package.json', + () => ({ version: 'mock-package-version' }), + { virtual: true } + ); + delete process.env.HEROKU_RELEASE_VERSION; delete process.env.AWS_LAMBDA_FUNCTION_VERSION; + appInfo = require('../../../lib'); + }); + + it('is set to the package.json version in the current working directory', () => { + expect(appInfo.releaseVersion).toBe('mock-package-version'); + }); + + describe('when the package.json has a non-string `version` property', () => { + beforeEach(() => { + jest.resetModules(); + jest.mock('/mock-cwd/package.json', () => ({ version: 123 }), { + virtual: true + }); + appInfo = require('../../../lib'); + }); + + it('is set to `null`', () => { + expect(appInfo.releaseVersion).toBe(null); + }); + }); + + describe('when the package.json is not an object', () => { + beforeEach(() => { + jest.resetModules(); + jest.mock('/mock-cwd/package.json', () => null, { virtual: true }); + appInfo = require('../../../lib'); + }); + + it('is set to `null`', () => { + expect(appInfo.releaseVersion).toBe(null); + }); + }); + }); + + describe('when neither environment variable is defined and a package.json does not exist', () => { + beforeEach(() => { + jest.unmock('/mock-cwd/package.json'); + jest.resetModules(); delete process.env.HEROKU_RELEASE_VERSION; + delete process.env.AWS_LAMBDA_FUNCTION_VERSION; appInfo = require('../../../lib'); }); - it('is set to null', () => { + it('is set to `null`', () => { expect(appInfo.releaseVersion).toBe(null); }); }); From b24c3f671edca4cf78a29d4e3c0ce6987052153f Mon Sep 17 00:00:00 2001 From: Rowan Manning <138944+rowanmanning@users.noreply.github.com> Date: Mon, 10 Jun 2024 14:09:58 +0100 Subject: [PATCH 2/4] feat: add semantic convention aliases This adds aliases for a bunch of app-info properties that align with OpenTelemetry's Semantic Conventions. This closes #818. --- packages/app-info/README.md | 15 +++++- packages/app-info/lib/index.js | 17 +++++++ packages/app-info/test/unit/lib/index.spec.js | 48 +++++++++++++++++++ packages/app-info/types/index.d.ts | 17 ++++++- 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/app-info/README.md b/packages/app-info/README.md index 302a99bf..d5678fa5 100644 --- a/packages/app-info/README.md +++ b/packages/app-info/README.md @@ -12,8 +12,9 @@ A utility to get application information (e.g. the system code) in a consistent * [`appInfo.systemCode`](#appinfosystemcode) * [`appInfo.processType`](#appinfoprocesstype) * [`appInfo.cloudProvider`](#appinfocloudprovider) - * [`appInfo.herokuAppId`](#appinfoherokuappId) - * [`appInfo.herokuDynoId`](#appinfoherokudynoId) + * [`appInfo.herokuAppId`](#appinfoherokuappid) + * [`appInfo.herokuDynoId`](#appinfoherokudynoid) + * [`appInfo.semanticConventions`](#appinfosemanticconventions) * [Migrating](#migrating) * [Contributing](#contributing) * [License](#license) @@ -99,6 +100,16 @@ Get the `process.env.HEROKU_DYNO_ID` which is the dyno identifier This is derived from the dyno metadata +### `appInfo.semanticConventions` + +This object contains aliases for the main `appInfo` properties that correspond to OpenTelemetry's [Semantic Conventions](https://opentelemetry.io/docs/concepts/semantic-conventions/). We use the following mapping: + + * `appInfo.semanticConventions.cloud.provider` aliases `appInfo.cloudProvider` + * `appInfo.semanticConventions.cloud.region` aliases `appInfo.region` + * `appInfo.semanticConventions.deployment.environment` aliases `appInfo.environment` + * `appInfo.semanticConventions.service.name` aliases `appInfo.systemCode` + * `appInfo.semanticConventions.service.version` aliases `appInfo.releaseVersion` + ## Migrating diff --git a/packages/app-info/lib/index.js b/packages/app-info/lib/index.js index aea2654c..db90e607 100644 --- a/packages/app-info/lib/index.js +++ b/packages/app-info/lib/index.js @@ -149,6 +149,23 @@ exports.herokuAppId = process.env.HEROKU_APP_ID || null; */ exports.herokuDynoId = process.env.HEROKU_DYNO_ID || null; +/** + * @type {import('@dotcom-reliability-kit/app-info').SemanticConventions} + */ +exports.semanticConventions = { + cloud: { + provider: exports.cloudProvider, + region: exports.region + }, + deployment: { + environment: exports.environment + }, + service: { + name: exports.systemCode, + version: exports.releaseVersion + } +}; + // @ts-ignore module.exports.default = module.exports; module.exports = Object.freeze(module.exports); diff --git a/packages/app-info/test/unit/lib/index.spec.js b/packages/app-info/test/unit/lib/index.spec.js index 903065d8..3bd94f42 100644 --- a/packages/app-info/test/unit/lib/index.spec.js +++ b/packages/app-info/test/unit/lib/index.spec.js @@ -349,6 +349,7 @@ describe('@dotcom-reliability-kit/app-info', () => { }); }); }); + describe('.herokuAppId', () => { it('returns HEROKU_APP_ID when process.env.HEROKU_APP_ID exists', () => { expect(appInfo.herokuAppId).toBe('mock-heroku-app-id'); @@ -361,6 +362,7 @@ describe('@dotcom-reliability-kit/app-info', () => { expect(appInfo.herokuAppId).toBe(null); }); }); + describe('.herokuDynoId', () => { it('returns HEROKU_DYNO_ID when `process.env.HEROKU_DYNO_ID` exists', () => { expect(appInfo.herokuDynoId).toBe('mock-heroku-dyno-id'); @@ -372,4 +374,50 @@ describe('@dotcom-reliability-kit/app-info', () => { expect(appInfo.herokuDynoId).toBe(null); }); }); + + describe('.semanticConventions', () => { + describe('.cloud', () => { + describe('.provider', () => { + it('is an alias of `cloudProvider`', () => { + expect(appInfo.semanticConventions.cloud.provider).toBe( + appInfo.cloudProvider + ); + }); + }); + + describe('.region', () => { + it('is an alias of `region`', () => { + expect(appInfo.semanticConventions.cloud.region).toBe(appInfo.region); + }); + }); + }); + + describe('.deployment', () => { + describe('.environment', () => { + it('is an alias of `environment`', () => { + expect(appInfo.semanticConventions.deployment.environment).toBe( + appInfo.environment + ); + }); + }); + }); + + describe('.service', () => { + describe('.name', () => { + it('is an alias of `systemCode`', () => { + expect(appInfo.semanticConventions.service.name).toBe( + appInfo.systemCode + ); + }); + }); + + describe('.version', () => { + it('is an alias of `releaseVersion`', () => { + expect(appInfo.semanticConventions.service.version).toBe( + appInfo.releaseVersion + ); + }); + }); + }); + }); }); diff --git a/packages/app-info/types/index.d.ts b/packages/app-info/types/index.d.ts index 7fe1f16d..fc66389e 100644 --- a/packages/app-info/types/index.d.ts +++ b/packages/app-info/types/index.d.ts @@ -10,6 +10,20 @@ declare module '@dotcom-reliability-kit/app-info' { export const herokuAppId: string | null; export const herokuDynoId: string | null; + export type SemanticConventions = { + cloud: { + provider: string | null, + region: string | null + }, + deployment: { + environment: string | null + }, + service: { + name: string | null + version: string | null + } + }; + type appInfo = { systemCode: typeof systemCode, processType: typeof processType, @@ -20,7 +34,8 @@ declare module '@dotcom-reliability-kit/app-info' { releaseVersion: typeof releaseVersion, cloudProvider: typeof cloudProvider, herokuAppId: typeof herokuAppId, - herokuDynoId: typeof herokuDynoId + herokuDynoId: typeof herokuDynoId, + semanticConventions: SemanticConventions }; export default appInfo; From 66d69bbc4abb68eea836ab3515a47649ae889259 Mon Sep 17 00:00:00 2001 From: Rowan Manning <138944+rowanmanning@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:54:19 +0100 Subject: [PATCH 3/4] feat: add a service ID property This is going to be required to send OpenTelemetry metrics as our systems will only accept metrics with the corresponding semantic resource attribute. --- packages/app-info/README.md | 6 +++ packages/app-info/lib/index.js | 14 ++++++- packages/app-info/test/unit/lib/index.spec.js | 41 +++++++++++++++++++ packages/app-info/types/index.d.ts | 9 +++- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/app-info/README.md b/packages/app-info/README.md index d5678fa5..11699cd6 100644 --- a/packages/app-info/README.md +++ b/packages/app-info/README.md @@ -14,6 +14,7 @@ A utility to get application information (e.g. the system code) in a consistent * [`appInfo.cloudProvider`](#appinfocloudprovider) * [`appInfo.herokuAppId`](#appinfoherokuappid) * [`appInfo.herokuDynoId`](#appinfoherokudynoid) + * [`appInfo.instanceId`](#appinfoinstanceid) * [`appInfo.semanticConventions`](#appinfosemanticconventions) * [Migrating](#migrating) * [Contributing](#contributing) @@ -100,6 +101,10 @@ Get the `process.env.HEROKU_DYNO_ID` which is the dyno identifier This is derived from the dyno metadata +### `appInfo.instanceId` + +Get the ID of the instance that's running the application. This is derived from `process.env.HEROKU_DYNO_ID` if present, otherwise it will be set to a random UUID that identifies the currently running process. + ### `appInfo.semanticConventions` This object contains aliases for the main `appInfo` properties that correspond to OpenTelemetry's [Semantic Conventions](https://opentelemetry.io/docs/concepts/semantic-conventions/). We use the following mapping: @@ -109,6 +114,7 @@ This object contains aliases for the main `appInfo` properties that correspond t * `appInfo.semanticConventions.deployment.environment` aliases `appInfo.environment` * `appInfo.semanticConventions.service.name` aliases `appInfo.systemCode` * `appInfo.semanticConventions.service.version` aliases `appInfo.releaseVersion` + * `appInfo.semanticConventions.service.instance.id` aliases `appInfo.instanceId` ## Migrating diff --git a/packages/app-info/lib/index.js b/packages/app-info/lib/index.js index db90e607..e7d994bd 100644 --- a/packages/app-info/lib/index.js +++ b/packages/app-info/lib/index.js @@ -1,4 +1,5 @@ const path = require('node:path'); +const { randomUUID } = require('node:crypto'); // This package relies on Heroku and AWS Lambda environment variables. // Documentation for these variables is available here: @@ -149,6 +150,14 @@ exports.herokuAppId = process.env.HEROKU_APP_ID || null; */ exports.herokuDynoId = process.env.HEROKU_DYNO_ID || null; +/** + * The ID of the running instance of the service. + * + * @readonly + * @type {string} + */ +exports.instanceId = process.env.HEROKU_DYNO_ID || randomUUID(); + /** * @type {import('@dotcom-reliability-kit/app-info').SemanticConventions} */ @@ -162,7 +171,10 @@ exports.semanticConventions = { }, service: { name: exports.systemCode, - version: exports.releaseVersion + version: exports.releaseVersion, + instance: { + id: exports.instanceId + } } }; diff --git a/packages/app-info/test/unit/lib/index.spec.js b/packages/app-info/test/unit/lib/index.spec.js index 3bd94f42..a4904439 100644 --- a/packages/app-info/test/unit/lib/index.spec.js +++ b/packages/app-info/test/unit/lib/index.spec.js @@ -1,3 +1,5 @@ +jest.mock('node:crypto'); + describe('@dotcom-reliability-kit/app-info', () => { let appInfo; @@ -375,6 +377,35 @@ describe('@dotcom-reliability-kit/app-info', () => { }); }); + describe('.instanceId', () => { + let randomUUID; + + beforeEach(() => { + jest.resetModules(); + process.env.HEROKU_DYNO_ID = 'mock-heroku-dyno-id'; + appInfo = require('../../../lib'); + }); + + it('is set to `process.env.HEROKU_DYNO_ID`', () => { + expect(appInfo.instanceId).toBe('mock-heroku-dyno-id'); + }); + + describe('when `process.env.HEROKU_DYNO_ID` is not defined', () => { + beforeEach(() => { + jest.resetModules(); + randomUUID = require('node:crypto').randomUUID; + randomUUID.mockReturnValue('mock-generated-uuid'); + delete process.env.HEROKU_DYNO_ID; + appInfo = require('../../../lib'); + }); + + it('is set to a random UUID', () => { + expect(randomUUID).toHaveBeenCalledTimes(1); + expect(appInfo.instanceId).toBe('mock-generated-uuid'); + }); + }); + }); + describe('.semanticConventions', () => { describe('.cloud', () => { describe('.provider', () => { @@ -418,6 +449,16 @@ describe('@dotcom-reliability-kit/app-info', () => { ); }); }); + + describe('.instance', () => { + describe('.id', () => { + it('is an alias of `instanceId`', () => { + expect(appInfo.semanticConventions.service.instance.id).toBe( + appInfo.instanceId + ); + }); + }); + }); }); }); }); diff --git a/packages/app-info/types/index.d.ts b/packages/app-info/types/index.d.ts index fc66389e..89956562 100644 --- a/packages/app-info/types/index.d.ts +++ b/packages/app-info/types/index.d.ts @@ -9,6 +9,7 @@ declare module '@dotcom-reliability-kit/app-info' { export const cloudProvider: string | null; export const herokuAppId: string | null; export const herokuDynoId: string | null; + export const instanceId: string; export type SemanticConventions = { cloud: { @@ -16,11 +17,14 @@ declare module '@dotcom-reliability-kit/app-info' { region: string | null }, deployment: { - environment: string | null + environment: string }, service: { name: string | null - version: string | null + version: string | null, + instance: { + id: string + } } }; @@ -35,6 +39,7 @@ declare module '@dotcom-reliability-kit/app-info' { cloudProvider: typeof cloudProvider, herokuAppId: typeof herokuAppId, herokuDynoId: typeof herokuDynoId, + instanceId: typeof instanceId, semanticConventions: SemanticConventions }; From f64c53887ff436d17162e031dff4213a283f3036 Mon Sep 17 00:00:00 2001 From: Rowan Manning <138944+rowanmanning@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:10:01 +0100 Subject: [PATCH 4/4] fix: default semantic attributes to undefined --- packages/app-info/README.md | 2 ++ packages/app-info/lib/index.js | 8 ++++---- packages/app-info/types/index.d.ts | 8 ++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/app-info/README.md b/packages/app-info/README.md index 11699cd6..a018c8cd 100644 --- a/packages/app-info/README.md +++ b/packages/app-info/README.md @@ -116,6 +116,8 @@ This object contains aliases for the main `appInfo` properties that correspond t * `appInfo.semanticConventions.service.version` aliases `appInfo.releaseVersion` * `appInfo.semanticConventions.service.instance.id` aliases `appInfo.instanceId` +> [!WARNING] +> While all other properties default to `null` if they can't be calculated, the semantic conventions properties default to `undefined`. This is to ensure better compatibility with OpenTelemetry SDKs. ## Migrating diff --git a/packages/app-info/lib/index.js b/packages/app-info/lib/index.js index e7d994bd..f286ff6f 100644 --- a/packages/app-info/lib/index.js +++ b/packages/app-info/lib/index.js @@ -163,15 +163,15 @@ exports.instanceId = process.env.HEROKU_DYNO_ID || randomUUID(); */ exports.semanticConventions = { cloud: { - provider: exports.cloudProvider, - region: exports.region + provider: exports.cloudProvider || undefined, + region: exports.region || undefined }, deployment: { environment: exports.environment }, service: { - name: exports.systemCode, - version: exports.releaseVersion, + name: exports.systemCode || undefined, + version: exports.releaseVersion || undefined, instance: { id: exports.instanceId } diff --git a/packages/app-info/types/index.d.ts b/packages/app-info/types/index.d.ts index 89956562..64f1ac43 100644 --- a/packages/app-info/types/index.d.ts +++ b/packages/app-info/types/index.d.ts @@ -13,15 +13,15 @@ declare module '@dotcom-reliability-kit/app-info' { export type SemanticConventions = { cloud: { - provider: string | null, - region: string | null + provider?: string, + region?: string }, deployment: { environment: string }, service: { - name: string | null - version: string | null, + name?: string + version?: string, instance: { id: string }