Skip to content

Broad app info improvements (required for OpenTelemetry metrics) #1082

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 12, 2024
Merged
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: 21 additions & 2 deletions packages/app-info/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ 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.instanceId`](#appinfoinstanceid)
* [`appInfo.semanticConventions`](#appinfosemanticconventions)
* [Migrating](#migrating)
* [Contributing](#contributing)
* [License](#license)
Expand Down Expand Up @@ -99,6 +101,23 @@ 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:

* `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`
* `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

Expand Down
94 changes: 53 additions & 41 deletions packages/app-info/lib/index.js
Original file line number Diff line number Diff line change
@@ -1,47 +1,34 @@
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:
//
// - 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.
Expand All @@ -55,9 +42,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)) ||
Expand Down Expand Up @@ -124,15 +108,15 @@ 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.
*
* @readonly
* @type {string | null}
*/
exports.systemCode = systemCode;
exports.systemCode = process.env.SYSTEM_CODE || manifestName;

/**
* The dyno process type.
Expand Down Expand Up @@ -166,6 +150,34 @@ 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}
*/
exports.semanticConventions = {
cloud: {
provider: exports.cloudProvider || undefined,
region: exports.region || undefined
},
deployment: {
environment: exports.environment
},
service: {
name: exports.systemCode || undefined,
version: exports.releaseVersion || undefined,
instance: {
id: exports.instanceId
}
}
};

// @ts-ignore
module.exports.default = module.exports;
module.exports = Object.freeze(module.exports);
138 changes: 136 additions & 2 deletions packages/app-info/test/unit/lib/index.spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
jest.mock('node:crypto');

describe('@dotcom-reliability-kit/app-info', () => {
let appInfo;

Expand Down Expand Up @@ -155,15 +157,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);
});
});
Expand Down Expand Up @@ -304,6 +351,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');
Expand All @@ -316,6 +364,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');
Expand All @@ -327,4 +376,89 @@ describe('@dotcom-reliability-kit/app-info', () => {
expect(appInfo.herokuDynoId).toBe(null);
});
});

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', () => {
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
);
});
});

describe('.instance', () => {
describe('.id', () => {
it('is an alias of `instanceId`', () => {
expect(appInfo.semanticConventions.service.instance.id).toBe(
appInfo.instanceId
);
});
});
});
});
});
});
22 changes: 21 additions & 1 deletion packages/app-info/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ 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: {
provider?: string,
region?: string
},
deployment: {
environment: string
},
service: {
name?: string
version?: string,
instance: {
id: string
}
}
};

type appInfo = {
systemCode: typeof systemCode,
Expand All @@ -20,7 +38,9 @@ declare module '@dotcom-reliability-kit/app-info' {
releaseVersion: typeof releaseVersion,
cloudProvider: typeof cloudProvider,
herokuAppId: typeof herokuAppId,
herokuDynoId: typeof herokuDynoId
herokuDynoId: typeof herokuDynoId,
instanceId: typeof instanceId,
semanticConventions: SemanticConventions
};

export default appInfo;
Expand Down
Loading