Skip to content

Commit

Permalink
feat: end-to-end run logic (feat/commander) (#81)
Browse files Browse the repository at this point in the history
* Add commander for CLI invocation
* Add logic to invoke scopes based on config file
* Start adding anonymization logic
  • Loading branch information
jdharvey-ibm committed Sep 19, 2023
1 parent 71654c7 commit 3042616
Show file tree
Hide file tree
Showing 42 changed files with 1,041 additions and 536 deletions.
11 changes: 1 addition & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@opentelemetry/sdk-metrics": "^1.15.2",
"@opentelemetry/semantic-conventions": "^1.15.2",
"ajv": "^8.12.0",
"commander": "^11.0.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"object-scan": "^19.0.2",
Expand Down
55 changes: 55 additions & 0 deletions src/main/core/anonymize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright IBM Corp. 2023, 2023
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { type Attributes } from '@opentelemetry/api'
import { createHash } from 'crypto'

type RequireAtLeastOne<T> = {
[K in keyof T]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<keyof T, K>>>
}[keyof T]

interface AnonymizeConfig<T extends Attributes> {
hash?: Array<keyof T>
substitute?: Array<keyof T>
}

/**
* Anonymizes incoming raw data. The keys to be anonymized are specified in the config object
* under either the `hash` key or the `substitute` key.
*
* @param raw - The attributes to anonymize.
* @param config - The keys to either hash or substitute.
* @returns The raw object will all specified keys replaced with anonymized versions of their
* values.
*/
export function anonymize<T extends Attributes>(
raw: T,
config: RequireAtLeastOne<AnonymizeConfig<T>>
) {
const anonymizedEntries = Object.entries(raw).map(([key, value]) => {
if (typeof value !== 'string') {
return { key, value }
}

if (config.hash?.includes(key) ?? false) {
const hash = createHash('sha256')
hash.update(value)
return { key, value: hash.digest('hex') }
}

if (config.substitute?.includes(key) ?? false) {
// TODO: implement this logic
return { key, value: 'substituted!' }
}

return { key, value }
})

return anonymizedEntries.reduce<Attributes>((prev, cur) => {
prev[cur.key] = cur.value
return prev
}, {})
}
20 changes: 13 additions & 7 deletions src/main/core/config/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,28 @@ import ajv, { type JSONSchemaType, type ValidateFunction } from 'ajv'
// TODO: this should come from a separate published package
import { type Schema as ConfigFileSchema } from '../../../schemas/Schema.js'
import { ConfigValidationError } from '../../exceptions/config-validation-error.js'
import { Loggable } from '../log/loggable.js'
import { type Logger } from '../log/logger.js'
import { Trace } from '../log/trace.js'

const Ajv = ajv.default

/**
* Class that validates a telemetrics configuration file. Instances of this class should not be used
* to analyze more than one config file. Instead, create new instances for separate validations.
*/
export class ConfigValidator {
private readonly validate: ValidateFunction<ConfigFileSchema>
export class ConfigValidator extends Loggable {
private readonly ajvValidate: ValidateFunction<ConfigFileSchema>

/**
* Constructs a new config file validator based on the provided config file schema.
*
* @param schema - Config file schema object.
* @param logger - A logger instance.
*/
public constructor(schema: JSONSchemaType<ConfigFileSchema>) {
this.validate = new Ajv({ allErrors: true, verbose: true }).compile(schema)
public constructor(schema: JSONSchemaType<ConfigFileSchema>, logger: Logger) {
super(logger)
this.ajvValidate = new Ajv({ allErrors: true, verbose: true }).compile(schema)
}

/**
Expand All @@ -37,11 +42,12 @@ export class ConfigValidator {
* @throws `ConfigValidationError` if the file did not pass schema validation.
* @returns True if the config file passed validation; does not return otherwise.
*/
public validateConfig(content: unknown): content is ConfigFileSchema {
if (!this.validate(content)) {
@Trace()
public validate(content: unknown): content is ConfigFileSchema {
if (!this.ajvValidate(content)) {
throw new ConfigValidationError(
// Construct an array of partial error objects to cut down on log/output noise
this.validate.errors?.map((err) => {
this.ajvValidate.errors?.map((err) => {
const { instancePath, keyword, message, params } = err
return {
instancePath,
Expand Down
53 changes: 53 additions & 0 deletions src/main/core/directory-enumerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright IBM Corp. 2023, 2023
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import path from 'path'

import { InvalidRootPathError } from '../exceptions/invalid-root-path-error.js'
import { Loggable } from './log/loggable.js'
import { Trace } from './log/trace.js'

/**
* Class capable of enumerating directories based on a leaf dir, root dir, and predicate function.
*/
export class DirectoryEnumerator extends Loggable {
/**
* Finds directories between leaf and root (inclusive) which satisfy the predicate.
*
* @param leaf - Leaf-most directory. This must be inside of the root directory.
* @param root - Root-most directory.
* @param predicate - Function to indicate whether or not each enumerated directory should be part
* of the result set.
* @returns A (possibly empty) array of directories.
*/
@Trace()
public async find(
leaf: string,
root: string,
predicate: (dir: string) => boolean | Promise<boolean>
): Promise<string[]> {
// Ensure the format is normalized
leaf = path.resolve(leaf)
root = path.resolve(root)

// (if leaf is not a subpath of root, throw an exception)
if (path.relative(root, leaf).startsWith('..')) {
throw new InvalidRootPathError(root, leaf)
}

const dirs = []

for (let cur = leaf; cur !== root; cur = path.resolve(cur, '..')) {
dirs.push(cur)
}
dirs.push(root)

const checks = await Promise.all(dirs.map(predicate))

return dirs.filter((_dir, index) => checks[index] === true)
}
}
6 changes: 4 additions & 2 deletions src/main/core/get-project-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { type Logger } from './log/logger.js'
import { runCommand } from './run-command.js'

/**
* Finds and returns the root-most directory of the analyzed project's source tree.
*
* @param cwd - Current working directory to use as the basis for finding the root directory.
* @param logger - Logger instance.
* @throws An exception if no usable root data was obtained.
* @returns The path of the analyzed project's root directory or null.
*/
export async function getProjectRoot(cwd: string): Promise<string> {
const result = await runCommand('git rev-parse --show-toplevel', { cwd })
export async function getProjectRoot(cwd: string, logger: Logger): Promise<string> {
const result = await runCommand('git rev-parse --show-toplevel', logger, { cwd })

return result.stdout
}
16 changes: 2 additions & 14 deletions src/main/core/initialize-open-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,20 @@
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import opentelemetry from '@opentelemetry/api'
import opentelemetry, { type Attributes } from '@opentelemetry/api'
import { Resource } from '@opentelemetry/resources'
import { MeterProvider } from '@opentelemetry/sdk-metrics'
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'

import { ManualMetricReader } from './manual-metric-reader.js'
import type * as ResourceAttributes from './resource-attributes.js'

interface InitializeOpenTelemetryConfig {
[ResourceAttributes.EMITTER_NAME]: string
[ResourceAttributes.EMITTER_VERSION]: string
[ResourceAttributes.PROJECT_ID]: string
[ResourceAttributes.ANALYZED_RAW]: string
[ResourceAttributes.ANALYZED_HOST]: string | undefined
[ResourceAttributes.ANALYZED_OWNER]: string | undefined
[ResourceAttributes.ANALYZED_REPOSITORY]: string | undefined
[ResourceAttributes.DATE]: string
}

/**
* Initializes the OpenTelemetry tooling based on the provided config.
*
* @param config - The configuration options needed to initialize OpenTelemetry.
* @returns A metric reader that will contain all collected data.
*/
function initializeOpenTelemetry(config: InitializeOpenTelemetryConfig) {
function initializeOpenTelemetry(config: Attributes) {
const resource = Resource.default().merge(
new Resource({
// By default, remove the service name attribute, since it is unused
Expand Down
13 changes: 11 additions & 2 deletions src/main/core/log/loggable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import { type Logger } from './logger.js'
/**
* Abstract class which enforces that all child classes define a logger instance.
*/
export abstract class Loggable {
protected abstract logger: Logger
export class Loggable {
protected logger: Logger

/**
* Constructs a loggable class.
*
* @param logger - The logger to use during logging.
*/
public constructor(logger: Logger) {
this.logger = logger
}
}
41 changes: 36 additions & 5 deletions src/main/core/log/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,46 @@ export class Logger {
}

/**
* Logs a given message to the log file.
* Debug logs a given log message.
*
* @param level - 'debug' or 'error'.
* @param msg - Message to log, can be a string or instance of Error class.
* @param msg - The message to log.
*/
public async log(level: Level, msg: string | Error) {
public async debug(msg: string) {
await this.log('debug', msg)
}

/**
* Error logs a given message/error.
*
* @param msg - The message/error to log.
*/
public async error(msg: string | Error) {
if (msg instanceof Error) {
msg = msg.stack ?? msg.name + ' ' + msg.message
}
await appendFile(this.filePath, `${level} ${new Date().toISOString()} ${msg}\n`)

await this.log('error', msg)
}

/**
* Logs a given message to the log file.
*
* @param level - 'debug' or 'error'.
* @param msg - Message to log.
*/
private async log(level: Level, msg: string) {
const date = new Date().toISOString()

if (process.env['NODE_ENV'] !== 'production') {
switch (level) {
case 'debug':
console.debug(level, date, msg)
break
case 'error':
console.error(level, date, msg)
}
}

await appendFile(this.filePath, level + ' ' + date + ' ' + msg + '\n')
}
}

0 comments on commit 3042616

Please sign in to comment.