diff --git a/.yarn/versions/4dea33b4.yml b/.yarn/versions/4dea33b4.yml new file mode 100644 index 00000000..080e8aff --- /dev/null +++ b/.yarn/versions/4dea33b4.yml @@ -0,0 +1,2 @@ +releases: + solarwinds-apm: major diff --git a/examples/next-prisma/solarwinds.apm.config.js b/examples/next-prisma/solarwinds.apm.config.js index 7961ceec..286b7807 100644 --- a/examples/next-prisma/solarwinds.apm.config.js +++ b/examples/next-prisma/solarwinds.apm.config.js @@ -1,6 +1,6 @@ const { PrismaInstrumentation } = require("@prisma/instrumentation") -/** @type {import("solarwinds-apm").ConfigFile} */ +/** @type {import("solarwinds-apm").Config} */ module.exports = { instrumentations: { configs: { diff --git a/packages/merged-config/.gitignore b/packages/merged-config/.gitignore deleted file mode 100644 index 849ddff3..00000000 --- a/packages/merged-config/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist/ diff --git a/packages/merged-config/.prettierignore b/packages/merged-config/.prettierignore deleted file mode 100644 index 849ddff3..00000000 --- a/packages/merged-config/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -dist/ diff --git a/packages/merged-config/README.md b/packages/merged-config/README.md deleted file mode 100644 index e54d31d2..00000000 --- a/packages/merged-config/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @solarwinds-apm/merged-config - -This package provides utilities to parse and merge configurations from an object (ie. a file) and the environment. diff --git a/packages/merged-config/package.json b/packages/merged-config/package.json deleted file mode 100644 index 0b835025..00000000 --- a/packages/merged-config/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@solarwinds-apm/merged-config", - "version": "1.0.0-pre.2", - "license": "Apache-2.0", - "contributors": [ - "Raphaël Thériault " - ], - "repository": { - "type": "git", - "url": "https://github.com/solarwindscloud/solarwinds-apm-js.git", - "directory": "packages/merged-config" - }, - "bugs": { - "url": "https://github.com/solarwindscloud/solarwinds-apm-js/issues" - }, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "files": [ - "dist/" - ], - "publishConfig": { - "provenance": true - }, - "scripts": { - "build": "tsc", - "lint": "prettier --check . && eslint . --max-warnings=0", - "lint:fix": "eslint --fix . && prettier --write .", - "publish": "node ../../scripts/publish.js", - "test": "swtest -p test/tsconfig.json -c src" - }, - "devDependencies": { - "@solarwinds-apm/eslint-config": "workspace:^", - "@solarwinds-apm/test": "workspace:^", - "@types/node": "^16.0.0", - "eslint": "^8.47.0", - "prettier": "^3.0.2", - "typescript": "^5.2.2" - }, - "engines": { - "node": ">=16" - }, - "stableVersion": "0.0.0" -} diff --git a/packages/merged-config/src/index.ts b/packages/merged-config/src/index.ts deleted file mode 100644 index b0863ec6..00000000 --- a/packages/merged-config/src/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* -Copyright 2023 SolarWinds Worldwide, LLC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** Config definition with each key defining the value it expects */ -export type ConfigDefinition = Record> -/** Config value definition */ -export interface ValueDefinition { - /** Whether to read this value from the file */ - file?: boolean - /** - * Whether to read this value from the environment - * - * If both `file` and `env` are true and the value is present in both, - * the environment value will be used. - * - * If a string is provided, it will be used as the environment variable name - * instead of the automatically converted key name. - */ - env?: boolean | string - /** Default value to use if none was provided */ - default?: T - /** Whether the value is required */ - required?: boolean - /** Optional custom parser for the value */ - parser?: (value: unknown) => T -} - -/** - * Parses a config following the provided definition using the provided file and current environment - * - * Config keys are used as-is when reading from the file - * but converted to SCREAMING_SNAKE_CASE when reading from the environment. - * The `envPrefix` will not be applied to keys with custom environment variable names. - * - * @param def - Config definition - * @param file - Contents of the parsed config file - * @param envPrefix - Optional prefix to use for environment variable names - */ -export function config( - def: T, - file: Partial>, - envPrefix = "", -): Config { - const config: Record = {} - for (const [key, vDef] of Object.entries(def)) { - let value = undefined - - if (vDef.file) value = file[key] - - if (vDef.env) { - if (typeof vDef.env === "string") value = process.env[vDef.env] - else value = process.env[`${envPrefix}${upcase(key)}`] - } - - if (value === undefined) { - if (vDef.required) throw new Error(`Missing config: ${key}`) - else value = vDef.default - } - - if (vDef.parser && value !== undefined) value = vDef.parser(value) - - config[key] = value - } - - return config as Config -} -export default config - -/** Maps the type of a config definition to the type of the config it produces */ -type Config = { - [K in keyof T]: Value | Optional -} -/** - * Maps the type of a value definition to the type of the value it produces - * - * By default the value can be any JSON value, but if a parser is provided - * we can narrow the type to the return type of said parser. - */ -type Value> = T["parser"] extends ( - value: unknown, -) => infer TT - ? TT - : unknown -/** - * Maps the type of a value definition to `undefined` if it is optional - * - * If the type is required or if the default value is not undefined, - * the value will always be present and we map to the `never` type instead. - * We can then use the mapped type in a union type since `T | undefined` is an optional `T` - * and `T | never` is always just `T`. - */ -type Optional> = T["required"] extends true - ? never - : T["default"] extends undefined - ? undefined - : never - -/** Converts a camelCase string to a SCREAMING_SNAKE_CASE one */ -function upcase(s: string): string { - return s.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase() -} diff --git a/packages/merged-config/test/index.test.ts b/packages/merged-config/test/index.test.ts deleted file mode 100644 index 04bf8b21..00000000 --- a/packages/merged-config/test/index.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* -Copyright 2023 SolarWinds Worldwide, LLC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { beforeEach, expect, it } from "@solarwinds-apm/test" - -import * as mc from "../src/index" - -beforeEach(() => { - for (const key of Object.keys(process.env)) { - if (key.startsWith("TEST_")) Reflect.deleteProperty(process.env, key) - } -}) - -it("should pick up file options", () => { - const file = { name: "Joe", age: 42 } - - const result = mc.config({ name: { file: true }, age: { file: true } }, file) - - expect(result).to.deep.equal(file) -}) - -it("should pick up env options", () => { - process.env.TEST_NAME = "Joe" - process.env.TEST_AGE = "42" - - const result = mc.config( - { name: { env: true }, age: { env: true } }, - {}, - "TEST_", - ) - - expect(result).to.deep.equal({ name: "Joe", age: "42" }) -}) - -it("should prioritise env over file", () => { - const file = { name: "Joe" } - process.env.TEST_NAME = "Jane" - - const result = mc.config({ name: { file: true, env: true } }, file, "TEST_") - - expect(result).to.deep.equal({ name: "Jane" }) -}) - -it("should properly case env", () => { - process.env.TEST_FULL_NAME = "Jane Doe" - - const result = mc.config({ fullName: { env: true } }, {}, "TEST_") - - expect(result).to.deep.equal({ fullName: "Jane Doe" }) -}) - -it("should support custom env name", () => { - process.env.TEST_NAME = "Jane Doe" - - const result = mc.config({ fullName: { env: "TEST_NAME" } }, {}, "TEST_") - - expect(result).to.deep.equal({ fullName: "Jane Doe" }) -}) - -it("should use default if not present", () => { - const result = mc.config({ name: { file: true, default: "Joe" } }, {}) - - expect(result).to.deep.equal({ name: "Joe" }) -}) - -it("should not use default if present", () => { - const result = mc.config( - { name: { file: true, default: "Joe" } }, - { name: "Jane" }, - ) - - expect(result).to.deep.equal({ name: "Jane" }) -}) - -it("should throw if required and not present", () => { - expect(() => - mc.config({ name: { file: true, required: true } }, {}), - ).to.throw(/name/) -}) - -it("should not throw if required and present", () => { - expect(() => - mc.config({ name: { file: true, required: true } }, { name: "Joe" }), - ).not.to.throw() -}) - -it("should use the provided parser", () => { - const file = { age: "42" } - - const result = mc.config( - { age: { file: true, parser: Number } }, - file, - "TEST_", - ) - - expect(result).to.deep.equal({ age: 42 }) -}) diff --git a/packages/merged-config/tsconfig.json b/packages/merged-config/tsconfig.json deleted file mode 100644 index b7997e97..00000000 --- a/packages/merged-config/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["./src/**/*"], - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - } -} diff --git a/packages/solarwinds-apm/CONFIGURATION.md b/packages/solarwinds-apm/CONFIGURATION.md index 133a0d8c..76e15bca 100644 --- a/packages/solarwinds-apm/CONFIGURATION.md +++ b/packages/solarwinds-apm/CONFIGURATION.md @@ -1,22 +1,9 @@ # Configuration -Configuration options are read from both then environment and an optional configuration file, with environment variables taking precedence over config file values. +Configuration options are read from both the environment and an optional configuration file, with environment variables taking precedence over config file values. All configuration options are optional except for the service key which is always required. -## Environment Variables - -| Name | Default | Description | -| ------------------------------ | ----------------- | ------------------------------------------------- | -| **`SW_APM_SERVICE_KEY`** | | **Service key** | -| `SW_APM_ENABLED` | `true` | Whether instrumentation should be enabled | -| `SW_APM_COLLECTOR` | Default collector | Collector URL | -| `SW_APM_TRUSTED_PATH` | None | Path to the collector's SSL certificate | -| `SW_APM_PROXY` | None | URL of a proxy to use to connect to the collector | -| `SW_APM_LOG_LEVEL` | `info` | Logging level for the instrumentation libraries | -| `SW_APM_TRIGGER_TRACE_ENABLED` | `true` | Whether trigger tracing should be enabled | -| `SW_APM_RUNTIME_METRICS` | `true` | Whether runtime metrics should be enabled | - ## Configuration File When required, the package will look for the file in the current working directory under three possible formats, in the following order: @@ -32,41 +19,41 @@ It's also possible to use a custom name for the configuration file using the `SW The package exports a type for the config file which can be used to type check it when using TypeScript or add a JSDoc type annotation when using JavaScript. ```ts -import type { ConfigFile } from "solarwinds-apm" +import type { Config } from "solarwinds-apm" -const config: ConfigFile = { +const config: Config = { // ... } export default config ``` ```js -/** @type {import('solarwinds-apm').ConfigFile} */ +/** @type {import('solarwinds-apm').Config} */ module.exports = { // ... } ``` -### Specification - -| Key | Default | Description | -| ------------------------------- | ----------------- | ------------------------------------------------------------ | -| **`serviceKey`** | | **Service key** | -| `enabled` | `true` | Whether instrumentation should be enabled | -| `collector` | Default collector | Collector URL | -| `trustedPath` | None | Path to the collector's SSL certificate | -| `proxy` | None | URL of a proxy to use to connect to the collector | -| `logLevel` | `info` | Logging level for the instrumentation libraries | -| `triggerTraceEnabled` | `true` | Whether trigger tracing should be enabled | -| `runtimeMetrics` | `true` | Whether runtime metrics should be enabled | -| `tracingMode` | None | Custom tracing mode | -| `insertTraceContextIntoLogs` | `false` | Whether to insert trace context information into logs | -| `insertTraceContextIntoQueries` | `false` | Whether to insert trace context information into SQL queries | -| `transactionSettings` | None | See [Transaction Settings](#transaction-settings) | -| `instrumentations` | None | See [Instrumentations](#instrumentations) | -| `metricViews` | None | Custom metric views | - -#### Transaction Settings +## Specification + +| Key | Environment | Default | Description | +| ------------------------------- | ------------------------------ | ----------------- | ------------------------------------------------------------ | +| **`serviceKey`** | **`SW_APM_SERVICE_KEY`** | | **Service key** | +| `enabled` | `SW_APM_ENABLED` | `true` | Whether instrumentation should be enabled | +| `collector` | `SW_APM_COLLECTOR` | Default collector | Collector URL | +| `trustedPath` | `SW_APM_TRUSTED_PATH` | None | Path to the collector's SSL certificate | +| `proxy` | `SW_APM_PROXY` | None | URL of a proxy to use to connect to the collector | +| `logLevel` | `SW_APM_LOG_LEVEL` | `info` | Logging level for the instrumentation libraries | +| `triggerTraceEnabled` | `SW_APM_TRIGGER_TRACE_ENABLED` | `true` | Whether trigger tracing should be enabled | +| `runtimeMetrics` | `SW_APM_RUNTIME_METRICS` | `true` | Whether runtime metrics should be collected | +| `tracingMode` | `SW_APM_TRACING_MODE` | None | Custom tracing mode | +| `insertTraceContextIntoLogs` | | `false` | Whether to insert trace context information into logs | +| `insertTraceContextIntoQueries` | | `false` | Whether to insert trace context information into SQL queries | +| `transactionSettings` | | None | See [Transaction Settings](#transaction-settings) | +| `instrumentations` | | None | See [Instrumentations](#instrumentations) | +| `metricViews` | | None | Custom metric views | + +### Transaction Settings Transaction settings allow filtering out certain transactions, based on URL for web requests, and the type and name concatenated with a colon for everything else. This option should be set to an array of objects. @@ -92,7 +79,7 @@ module.exports = { } ``` -#### Instrumentations +### Instrumentations A default set of instrumentations are provided and configured by the library. However in many cases it may be desirable to manually configure the instrumentations or provide additional ones. The `instrumentations` configuration field accepts an object which in turn can contain two fields. diff --git a/packages/solarwinds-apm/package.json b/packages/solarwinds-apm/package.json index cfe77c87..25f39c7b 100644 --- a/packages/solarwinds-apm/package.json +++ b/packages/solarwinds-apm/package.json @@ -41,7 +41,8 @@ "build": "tsc", "lint": "prettier --check . && eslint . --max-warnings=0", "lint:fix": "eslint --fix . && prettier --write .", - "publish": "node ../../scripts/publish.js" + "publish": "node ../../scripts/publish.js", + "test": "swtest -p test/tsconfig.json -c src" }, "dependencies": { "@opentelemetry/auto-instrumentations-node": "^0.39.0", @@ -52,8 +53,8 @@ "@opentelemetry/sdk-trace-node": "1.15.x", "@opentelemetry/semantic-conventions": "1.15.x", "@solarwinds-apm/bindings": "workspace:^", - "@solarwinds-apm/merged-config": "workspace:^", - "@solarwinds-apm/sdk": "workspace:^" + "@solarwinds-apm/sdk": "workspace:^", + "zod": "^3.22.2" }, "peerDependencies": { "@opentelemetry/api": "1.4.x", @@ -74,6 +75,7 @@ "devDependencies": { "@opentelemetry/api": "1.4.x", "@solarwinds-apm/eslint-config": "workspace:^", + "@solarwinds-apm/test": "workspace:^", "@types/node": "^16.0.0", "eslint": "^8.47.0", "json5": "^2.2.3", diff --git a/packages/solarwinds-apm/src/config.ts b/packages/solarwinds-apm/src/config.ts index c590e088..c1f9f446 100644 --- a/packages/solarwinds-apm/src/config.ts +++ b/packages/solarwinds-apm/src/config.ts @@ -20,12 +20,12 @@ import * as process from "node:process" import { DiagLogLevel } from "@opentelemetry/api" import { type InstrumentationConfigMap } from "@opentelemetry/auto-instrumentations-node" -import { type Instrumentation } from "@opentelemetry/instrumentation" -import { type View } from "@opentelemetry/sdk-metrics" +import { InstrumentationBase } from "@opentelemetry/instrumentation" +import { View } from "@opentelemetry/sdk-metrics" import { oboe } from "@solarwinds-apm/bindings" -import * as mc from "@solarwinds-apm/merged-config" import { type SwConfiguration } from "@solarwinds-apm/sdk" import { type Service } from "ts-node" +import { z } from "zod" import aoCert from "./appoptics.crt" @@ -45,21 +45,135 @@ try { tsNode = undefined } -export interface ConfigFile { - serviceKey?: string - enabled?: boolean - collector?: string - trustedPath?: string - logLevel?: LogLevel - triggerTraceEnabled?: boolean - runtimeMetrics?: boolean - tracingMode?: TracingMode - insertTraceContextIntoLogs?: boolean - transactionSettings?: TransactionSetting[] +const boolean = z.union([ + z.boolean(), + z + .enum(["true", "false", "1", "0"]) + .transform((b) => b === "true" || b === "1"), +]) + +const regex = z.union([ + z.instanceof(RegExp), + z.string().transform((s, ctx) => { + try { + return new RegExp(s) + } catch (err) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: (err as SyntaxError).message, + }) + return z.NEVER + } + }), +]) + +const serviceKey = z + .string() + .includes(":") + .transform((k) => { + const [token, ...name] = k.split(":") + return { token: token!, name: name.join(":") } + }) + +const trustedPath = z.string().transform((p, ctx) => { + try { + return fs.readFileSync(p, "utf-8") + } catch (err) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: (err as NodeJS.ErrnoException).message, + }) + return z.NEVER + } +}) + +const tracingMode = z + .enum(["enabled", "disabled"]) + .transform((m) => m === "enabled") + +const logLevel = z + .enum(["verbose", "debug", "info", "warn", "error", "none"]) + .transform((l) => { + switch (l) { + case "verbose": + return DiagLogLevel.VERBOSE + case "debug": + return DiagLogLevel.DEBUG + case "info": + return DiagLogLevel.INFO + case "warn": + return DiagLogLevel.WARN + case "error": + return DiagLogLevel.ERROR + case "none": + return DiagLogLevel.NONE + } + }) + +const transactionSettings = z.array( + z + .union([ + z.object({ + tracing: tracingMode, + regex, + }), + z.object({ + tracing: tracingMode, + matcher: z.function().args(z.string()).returns(z.boolean()), + }), + ]) + .transform((s) => ({ + tracing: s.tracing, + matcher: + "matcher" in s ? s.matcher : (ident: string) => s.regex.test(ident), + })), +) + +interface Instrumentations { + configs?: InstrumentationConfigMap + extra?: InstrumentationBase[] +} + +interface Metrics { + views?: View[] +} + +const schema = z.object({ + serviceKey, + enabled: boolean.default(true), + collector: z.string().optional(), + trustedPath: trustedPath.optional(), + proxy: z.string().optional(), + logLevel: logLevel.default("info"), + triggerTraceEnabled: boolean.default(true), + runtimeMetrics: boolean.default(true), + tracingMode: tracingMode.optional(), + insertTraceContextIntoLogs: boolean.default(false), + insertTraceContextIntoQueries: boolean.default(false), + transactionSettings: transactionSettings.optional(), + instrumentations: z + .object({ + configs: z.record(z.unknown()).optional(), + extra: z.array(z.instanceof(InstrumentationBase)).optional(), + }) + .transform((i) => i as Instrumentations) + .optional(), + metrics: z + .object({ views: z.array(z.instanceof(View)).optional() }) + .optional(), +}) + +export interface Config extends Partial> { + instrumentations?: Instrumentations + metrics?: Metrics +} + +export interface ExtendedSwConfiguration extends SwConfiguration { instrumentations?: Instrumentations - metricViews?: View[] + metrics?: Metrics } +const ENV_PREFIX = "SW_APM_" const DEFAULT_FILE_NAME = "solarwinds.apm.config" enum FileType { Json, @@ -68,127 +182,43 @@ enum FileType { None, } -interface ServiceKey { - token: string - name: string -} -type LogLevel = "verbose" | "debug" | "info" | "warn" | "error" | "none" -type TracingMode = "enabled" | "disabled" -type TransactionSetting = { - tracing: TracingMode -} & ( - | { regex: RegExp | string } - | { matcher: (identifier: string) => boolean | undefined } -) -interface Instrumentations { - configs?: InstrumentationConfigMap - extra?: Instrumentation[] -} - -export interface ExtendedSwConfiguration extends SwConfiguration { - instrumentations?: Instrumentations - metricViews?: View[] -} - export function readConfig(): ExtendedSwConfiguration { + const env = Object.fromEntries( + Object.entries(process.env) + .filter(([k]) => k.startsWith(ENV_PREFIX)) + .map(([k, v]) => [fromEnvKey(k), v]), + ) + + let file: Record const [path, type] = pathAndType() - let configFile: ConfigFile switch (type) { case FileType.Ts: { - configFile = readTsConfig(path) + file = readTsConfig(path) break } case FileType.Js: { - configFile = readJsConfig(path) + file = readJsConfig(path) break } case FileType.Json: { - configFile = readJsonConfig(path) + file = readJsonConfig(path) break } case FileType.None: - configFile = {} + file = {} } - const raw = mc.config( - { - serviceKey: { - env: true, - file: true, - parser: parseServiceKey, - required: true, - }, - enabled: { - env: true, - file: true, - parser: parseBoolean({ name: "enabled", default: true }), - default: true, - }, - collector: { env: true, file: true, parser: String }, - trustedPath: { env: true, file: true, parser: String }, - proxy: { env: true, file: true, parser: String }, - logLevel: { - env: true, - file: true, - parser: parseLogLevel, - default: "info", - }, - triggerTraceEnabled: { - env: true, - file: true, - parser: parseBoolean({ name: "trigger trace", default: true }), - default: true, - }, - runtimeMetrics: { - env: true, - file: true, - parser: parseBoolean({ name: "runtime metrics", default: true }), - default: true, - }, - tracingMode: { - file: true, - parser: parseTracingMode, - }, - insertTraceContextIntoLogs: { - file: true, - parser: parseBoolean({ - name: "insert trace ids into logs", - default: false, - }), - default: false, - }, - insertTraceContextIntoQueries: { - file: true, - parser: parseBoolean({ - name: "insert trace ids into SQL queries", - default: false, - }), - default: false, - }, - transactionSettings: { file: true, parser: parseTransactionSettings }, - instrumentations: { - file: true, - parser: (v) => v as Instrumentations, - }, - metricViews: { file: true, parser: (v) => v as View[] }, - }, - configFile as Record, - "SW_APM_", - ) + const raw = schema.parse({ ...file, ...env }) + const config: ExtendedSwConfiguration = { ...raw, token: raw.serviceKey.token, serviceName: raw.serviceKey.name, + certificate: raw.trustedPath, oboeLogLevel: otelLevelToOboeLevel(raw.logLevel), otelLogLevel: raw.logLevel, } - if (raw.trustedPath) { - config.certificate = fs.readFileSync(raw.trustedPath, { - encoding: "utf8", - }) - } - if (config.collector?.includes("appoptics.com")) { config.metricFormat ??= 1 config.certificate ??= aoCert @@ -197,6 +227,56 @@ export function readConfig(): ExtendedSwConfiguration { return config } +export function printError(err: unknown) { + if (err instanceof z.ZodError) { + const formatPath = (path: (string | number)[]) => + path.length === 1 + ? // `key (SW_APM_KEY)` + `${path[0]!} (${toEnvKey(path[0]!.toString())})` + : // `full.key[0].path` + path + .map((p) => (typeof p === "string" ? `.${p}` : `[${p}]`)) + .join("") + .slice(1) + + const formatIssue = + (depth: number) => + (issue: z.ZodIssue): string[] => { + const indent = " ".repeat(depth * 2) + const messages = [ + `${indent}${formatPath(issue.path)}: ${issue.message}`, + ] + + if (issue.code === z.ZodIssueCode.invalid_union) { + messages.push( + ...issue.unionErrors.flatMap((e) => + e.issues.flatMap(formatIssue(depth + 1)), + ), + ) + } + + return messages + } + + for (const issue of err.issues.flatMap(formatIssue(1))) { + console.warn(issue) + } + } else { + console.warn(err) + } +} + +function fromEnvKey(k: string) { + return k + .slice(ENV_PREFIX.length) + .toLowerCase() + .replace(/_[a-z]/g, (c) => c.slice(1).toUpperCase()) +} + +function toEnvKey(k: string) { + return `${ENV_PREFIX}${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}` +} + function pathAndType(): [path: string, type: FileType] { const cwd = process.cwd() let override = process.env.SW_APM_CONFIG_FILE @@ -239,12 +319,12 @@ function pathAndType(): [path: string, type: FileType] { function readJsonConfig(file: string) { const contents = fs.readFileSync(file, { encoding: "utf8" }) - return json.parse(contents) as ConfigFile + return json.parse(contents) as Record } function readJsConfig(file: string) { // eslint-disable-next-line @typescript-eslint/no-var-requires - return require(file) as ConfigFile + return require(file) as Record } let tsNodeService: Service | undefined = undefined @@ -258,11 +338,13 @@ function readTsConfig(file: string) { tsNodeService.enabled(true) // eslint-disable-next-line @typescript-eslint/no-var-requires const required = require(file) as - | ConfigFile - | { __esModule: true; default: ConfigFile } + | { __esModule: true; default: Record } + | Record tsNodeService.enabled(false) - return "__esModule" in required ? required.default : required + return "__esModule" in required + ? (required.default as Record) + : required } function otelLevelToOboeLevel(level?: DiagLogLevel): number { @@ -285,148 +367,3 @@ function otelLevelToOboeLevel(level?: DiagLogLevel): number { return oboe.DEBUG_INFO } } - -const parseBoolean = - (options: { name: string; default: boolean }) => (value: unknown) => { - switch (typeof value) { - case "boolean": - return value - case "string": { - switch (value.toLowerCase()) { - case "true": - return true - case "false": - return false - default: { - console.warn(`invalid ${options.name} boolean value "${value}"`) - return options.default - } - } - } - default: { - console.warn(`invalid ${options.name} boolean value`) - return options.default - } - } - } - -function parseServiceKey(key: unknown): ServiceKey { - const s = String(key) - const parts = s.split(":") - if (parts.length !== 2) { - console.warn("invalid service key") - } - - return { - token: parts[0] ?? s, - name: parts[1] ?? "", - } -} - -function parseLogLevel(level: unknown): DiagLogLevel { - if (typeof level !== "string") { - console.warn(`invalid log level`) - return DiagLogLevel.INFO - } - - switch (level.toLowerCase()) { - case "verbose": - return DiagLogLevel.VERBOSE - case "debug": - return DiagLogLevel.DEBUG - case "info": - return DiagLogLevel.INFO - case "warn": - return DiagLogLevel.WARN - case "error": - return DiagLogLevel.ERROR - case "none": - return DiagLogLevel.NONE - default: { - console.warn(`invalid log level "${level}"`) - return DiagLogLevel.INFO - } - } -} - -function parseTracingMode(mode: unknown): boolean | undefined { - if (typeof mode !== "string") { - console.warn(`invalid tracing mode`) - return undefined - } - - switch (mode.toLowerCase()) { - case "enabled": - return true - case "disabled": - return false - default: { - console.warn(`invalid tracing mode "${mode}"`) - return undefined - } - } -} - -function parseTransactionSettings(settings: unknown) { - const result: SwConfiguration["transactionSettings"] = [] - - if (!Array.isArray(settings)) { - console.warn(`invalid transaction settings`) - return result - } - - for (let i = 0; i < settings.length; i++) { - const setting = settings[i] as unknown - const error = `invalid transaction setting at index ${i}` - - if (typeof setting !== "object" || setting === null) { - console.warn(`${error}, should be an object, ignoring`) - continue - } - - if ( - !("tracing" in setting) || - !(["enabled", "disabled"] as unknown[]).includes(setting.tracing) - ) { - console.warn( - `${error}, "tracing" must be "enabled" or "disabled", ignoring`, - ) - continue - } - const tracing = setting.tracing === "enabled" - - let matcher: (identifier: string) => boolean - if ("regex" in setting) { - const regex = setting.regex - if (typeof regex === "string") { - try { - const parsed = new RegExp(regex) - matcher = (identifier) => parsed.test(identifier) - } catch { - console.warn( - `${error}, "regex" is not a valid regular expression, ignoring`, - ) - continue - } - } else if (regex instanceof RegExp) { - matcher = (identifier) => regex.test(identifier) - } else { - console.warn(`${error}, "regex" must be a string or a RegExp, ignoring`) - continue - } - } else if ("matcher" in setting) { - if (typeof setting.matcher !== "function") { - console.warn(`${error}, "matcher" must be a function, ignoring`) - continue - } - matcher = setting.matcher as (identifier: string) => boolean - } else { - console.warn(`${error}, must have either "regex" or "matcher", ignoring`) - continue - } - - result.push({ tracing, matcher }) - } - - return result -} diff --git a/packages/solarwinds-apm/src/index.ts b/packages/solarwinds-apm/src/index.ts index 48a6fcbd..880d0bef 100644 --- a/packages/solarwinds-apm/src/index.ts +++ b/packages/solarwinds-apm/src/index.ts @@ -32,4 +32,4 @@ export function waitUntilAgentReady(timeout: number): number { return sdk.waitUntilAgentReady(timeout) } -export { type ConfigFile } from "./config" +export { type Config } from "./config" diff --git a/packages/solarwinds-apm/src/init.ts b/packages/solarwinds-apm/src/init.ts index bf7d8b8f..7f0eb36d 100644 --- a/packages/solarwinds-apm/src/init.ts +++ b/packages/solarwinds-apm/src/init.ts @@ -43,7 +43,7 @@ import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions" import { oboe } from "@solarwinds-apm/bindings" import * as sdk from "@solarwinds-apm/sdk" -import { type ExtendedSwConfiguration, readConfig } from "./config" +import { type ExtendedSwConfiguration, printError, readConfig } from "./config" export function init() { // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -62,18 +62,28 @@ export function init() { configurable: false, }) - const config = readConfig() + let config: ExtendedSwConfiguration + try { + config = readConfig() + } catch (err) { + console.warn( + "Invalid SolarWinds APM configuration, application will not be instrumented", + ) + printError(err) + return + } diag.setLogger(new DiagConsoleLogger(), config.otelLogLevel) - const initLogger = diag.createComponentLogger({ namespace: "sw/init" }) + const logger = diag.createComponentLogger({ namespace: "sw/init" }) if (!config.enabled) { - initLogger.info("Library disabled, application will not be instrumented") + logger.info("Library disabled, application will not be instrumented") return } - if (!config.serviceName) { - initLogger.warn( - "Invalid service key, application will not be instrumented", + if (sdk.OBOE_ERROR) { + logger.warn( + "Unsupported platform, application will not be instrumented", + sdk.OBOE_ERROR, ) return } @@ -90,15 +100,8 @@ export function init() { }), ) - initTracing(config, resource, packageJson.version, initLogger) - switch (config.runtimeMetrics) { - case true: { - initMetrics(config, resource, initLogger) - break - } - case false: - break - } + initTracing(config, resource, packageJson.version) + initMetrics(config, resource, logger) } } @@ -106,16 +109,7 @@ function initTracing( config: ExtendedSwConfiguration, resource: Resource, version: string, - logger: DiagLogger, ) { - if (sdk.OBOE_ERROR) { - logger.warn( - "Unsupported platform, application will not be instrumented", - sdk.OBOE_ERROR, - ) - return - } - const reporter = sdk.createReporter(config) oboe.debug_log_add((module, level, sourceName, sourceLine, message) => { @@ -189,14 +183,6 @@ function initMetrics( resource: Resource, logger: DiagLogger, ) { - if (sdk.METRICS_ERROR) { - logger.warn( - "Unsupported platform, metrics will not be collected", - sdk.METRICS_ERROR, - ) - return - } - const exporter = new sdk.SwMetricsExporter( diag.createComponentLogger({ namespace: "sw/metrics" }), ) @@ -208,12 +194,22 @@ function initMetrics( const provider = new MeterProvider({ resource, - views: config.metricViews, + views: config.metrics?.views, }) provider.addMetricReader(reader) metrics.setGlobalMeterProvider(provider) - sdk.metrics.start() + if (config.runtimeMetrics) { + if (sdk.METRICS_ERROR) { + logger.warn( + "Unsupported platform, runtime metrics will not be collected", + sdk.METRICS_ERROR, + ) + return + } + + sdk.metrics.start() + } } export function oboeLevelToOtelLogger( diff --git a/packages/solarwinds-apm/test/config.test.ts b/packages/solarwinds-apm/test/config.test.ts new file mode 100644 index 00000000..6c46a156 --- /dev/null +++ b/packages/solarwinds-apm/test/config.test.ts @@ -0,0 +1,105 @@ +/* +Copyright 2023 SolarWinds Worldwide, LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { DiagLogLevel } from "@opentelemetry/api" +import { oboe } from "@solarwinds-apm/bindings" +import { beforeEach, describe, expect, it } from "@solarwinds-apm/test" + +import aoCert from "../src/appoptics.crt" +import { type ExtendedSwConfiguration, readConfig } from "../src/config" + +describe("readConfig", () => { + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith("SW_APM_")) Reflect.deleteProperty(process.env, key) + } + process.env.SW_APM_SERVICE_KEY = "token:name" + }) + + it("returns proper defaults", () => { + const config = readConfig() + const expected: ExtendedSwConfiguration = { + token: "token", + serviceName: "name", + enabled: true, + otelLogLevel: DiagLogLevel.INFO, + oboeLogLevel: oboe.DEBUG_INFO, + triggerTraceEnabled: true, + runtimeMetrics: true, + insertTraceContextIntoLogs: false, + insertTraceContextIntoQueries: false, + } + + expect(config).to.include(expected) + }) + + it("parses booleans", () => { + process.env.SW_APM_ENABLED = "0" + + const config = readConfig() + expect(config).to.include({ enabled: false }) + }) + + it("parses tracing mode", () => { + process.env.SW_APM_TRACING_MODE = "enabled" + + const config = readConfig() + expect(config).to.include({ tracingMode: true }) + }) + + it("parses trusted path", () => { + process.env.SW_APM_TRUSTED_PATH = "package.json" + + const config = readConfig() + expect(config.certificate).to.include("solarwinds-apm") + }) + + it("parses transaction settings", () => { + process.env.SW_APM_CONFIG_FILE = "test/test.config.js" + + const config = readConfig() + expect(config.transactionSettings).not.to.be.undefined + expect(config.transactionSettings).to.have.length(3) + }) + + it("throws on bad boolean", () => { + process.env.SW_APM_ENABLED = "foo" + + expect(readConfig).to.throw() + }) + + it("throws on bad tracing mode", () => { + process.env.SW_APM_TRACING_MODE = "foo" + + expect(readConfig).to.throw() + }) + + it("throws on non-existent trusted path", () => { + process.env.SW_APM_TRUSTED_PATH = "foo" + + expect(readConfig).to.throw() + }) + + it("uses the right defaults for AppOptics", () => { + process.env.SW_APM_COLLECTOR = "collector.appoptics.com" + + const config = readConfig() + expect(config).to.include({ + metricFormat: 1, + certificate: aoCert, + }) + }) +}) diff --git a/packages/merged-config/eslint.config.js b/packages/solarwinds-apm/test/test.config.js similarity index 72% rename from packages/merged-config/eslint.config.js rename to packages/solarwinds-apm/test/test.config.js index 975e0973..b26ab3df 100644 --- a/packages/merged-config/eslint.config.js +++ b/packages/solarwinds-apm/test/test.config.js @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -const base = require("@solarwinds-apm/eslint-config") - -module.exports = [...base] +module.exports = { + transactionSettings: [ + { tracing: "enabled", regex: /^hello$/ }, + { tracing: "disabled", regex: "[A-Z]" }, + { tracing: "enabled", matcher: (ident) => ident.startsWith("foo") }, + ], +} diff --git a/packages/merged-config/test/tsconfig.json b/packages/solarwinds-apm/test/tsconfig.json similarity index 100% rename from packages/merged-config/test/tsconfig.json rename to packages/solarwinds-apm/test/tsconfig.json diff --git a/yarn.lock b/yarn.lock index 88fba88e..e2518b7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1946,19 +1946,6 @@ __metadata: languageName: unknown linkType: soft -"@solarwinds-apm/merged-config@workspace:^, @solarwinds-apm/merged-config@workspace:packages/merged-config": - version: 0.0.0-use.local - resolution: "@solarwinds-apm/merged-config@workspace:packages/merged-config" - dependencies: - "@solarwinds-apm/eslint-config": "workspace:^" - "@solarwinds-apm/test": "workspace:^" - "@types/node": "npm:^16.0.0" - eslint: "npm:^8.47.0" - prettier: "npm:^3.0.2" - typescript: "npm:^5.2.2" - languageName: unknown - linkType: soft - "@solarwinds-apm/scripts@workspace:scripts": version: 0.0.0-use.local resolution: "@solarwinds-apm/scripts@workspace:scripts" @@ -7196,14 +7183,15 @@ __metadata: "@opentelemetry/semantic-conventions": "npm:1.15.x" "@solarwinds-apm/bindings": "workspace:^" "@solarwinds-apm/eslint-config": "workspace:^" - "@solarwinds-apm/merged-config": "workspace:^" "@solarwinds-apm/sdk": "workspace:^" + "@solarwinds-apm/test": "workspace:^" "@types/node": "npm:^16.0.0" eslint: "npm:^8.47.0" json5: "npm:^2.2.3" prettier: "npm:^3.0.2" ts-node: "npm:^10.9.1" typescript: "npm:^5.2.2" + zod: "npm:^3.22.2" peerDependencies: "@opentelemetry/api": 1.4.x json5: ">=1.0.0" @@ -8172,3 +8160,10 @@ __metadata: checksum: 161e8cf7aea38a99244d65da4a9477d9d966f6a533e503feaa20ff7968a9691065c38c6f1eab5cbbdc8374142fff4a05c9cacb8479803ab50ab6a6ca80e5d624 languageName: node linkType: hard + +"zod@npm:^3.22.2": + version: 3.22.2 + resolution: "zod@npm:3.22.2" + checksum: 10afd994bcec3affb81776adc486871bfa6790037f57f0e45280e99baed8c3df5f46c5ab65a6d7921a00dffd9fab299e4492519dd6bb5d2eb3de9a67d3eb8051 + languageName: node + linkType: hard