Skip to content

Commit

Permalink
feat(trail): expose withLogTrail and getResourceNameFromFileName
Browse files Browse the repository at this point in the history
  • Loading branch information
uladkasach committed May 25, 2024
1 parent 24b187f commit ca384aa
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 53 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
sourceType: 'module', // Allows for the use of imports
},
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'error', // makes code-reviews easier + code quality better by explicitly defining outputs of exported functions+classes
'@typescript-eslint/explicit-module-boundary-types': 'warn', // makes code-reviews easier + code quality better by explicitly defining outputs of exported functions+classes
'@typescript-eslint/explicit-function-return-type': 'off', // prefer '@typescript-eslint/explicit-module-boundary-types' since it only requires the check on exported functions+classes
'sort-imports': 'off',
'import/prefer-default-export': 'off', // default export = bad
Expand Down
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
{
"name": "visualogic",
"author": "ehmpathy",
"description": "tools that aid the declaration of domain glossaries",
"description": "visualize your domain.logic",
"version": "0.0.0",
"repository": "ehmpathy/visualogic",
"homepage": "https://github.com/ehmpathy/visualogic",
"keywords": [
"domain",
"domain-glossary",
"domain-glossaries",
"glossary",
"domain-driven-design"
"logic",
"visualization",
"observability",
"observe",
"trace"
],
"bugs": "https://github.com/ehmpathy/visualogic/issues",
"license": "MIT",
Expand Down
46 changes: 0 additions & 46 deletions src/AsOfGlossary.ts

This file was deleted.

36 changes: 36 additions & 0 deletions src/domain/Procedure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* what: the shape of an observable procedure
*
* what^2:
* - observable = easy to read, monitor, and maintain
* - procedure = an executable of a tactic; tactic.how.procedure::Executable
*
* note
* - javascript's "functions" are actually, by definition, procedures
*/
export type Procedure = (
/**
* the input of the procedure
*/
input: any,

/**
* the context within which the procedure runs
*/
context?: any,
) => any;

/**
* extracts the input::Type of a procedure
*/
export type ProcedureInput<T extends Procedure> = Parameters<T>[0];

/**
* extracts the context::Type of a procedure
*/
export type ProcedureContext<T extends Procedure> = Parameters<T>[1];

/**
* extracts the output::Type of a procedure
*/
export type ProcedureOutput<T extends Procedure> = ReturnType<T>;
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './AsOfGlossary';
export * from './logic/withLogTrail';
export * from './logic/getResourceNameFromFileName';
3 changes: 3 additions & 0 deletions src/logic/asDomainProcedure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { withLogTrail } from './withLogTrail';

export const asDomainProcedure = withLogTrail; // todo: also, ask for what, why, when, how.idea
2 changes: 2 additions & 0 deletions src/logic/getResourceNameFromFileName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const getResourceNameFromFileName = (fileName: string): string =>
fileName.split('/').slice(-1)[0]!.split('.').slice(0, -1).join('.');
83 changes: 83 additions & 0 deletions src/logic/withLogTrail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { withLogTrail } from './withLogTrail';

const logDebugSpy = jest.spyOn(console, 'log');

export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

describe('withLogTrail', () => {
beforeEach(() => jest.clearAllMocks());
describe('input output logging', () => {
it('should be log input and output for a sync fn', () => {
const castToUpperCase = withLogTrail(
({ name }: { name: string }) => {
return name.toUpperCase();
},
{ name: 'castToUpperCase', log: { method: console.log } },
);

// should run like normal
const uppered = castToUpperCase({ name: 'casey' });
expect(uppered).toEqual('CASEY');

// should have logged input and output
expect(logDebugSpy).toHaveBeenCalledTimes(2);
expect(logDebugSpy).toHaveBeenNthCalledWith(1, 'castToUpperCase.input', {
input: { name: 'casey' },
});
expect(logDebugSpy).toHaveBeenNthCalledWith(2, 'castToUpperCase.output', {
input: { name: 'casey' },
output: ['CASEY'],
});
});
it('should be log input and output for an async fn', async () => {
const castToUpperCase = withLogTrail(
async ({ name }: { name: string }) => {
await sleep(100);
return name.toUpperCase();
},
{ name: 'castToUpperCase', log: { method: console.log } },
);

// should run like normal
const uppered = await castToUpperCase({ name: 'casey' });
expect(uppered).toEqual('CASEY');

// should have logged input and output
expect(logDebugSpy).toHaveBeenCalledTimes(2);
expect(logDebugSpy).toHaveBeenNthCalledWith(1, 'castToUpperCase.input', {
input: { name: 'casey' },
});
expect(logDebugSpy).toHaveBeenNthCalledWith(2, 'castToUpperCase.output', {
input: { name: 'casey' },
output: ['CASEY'],
});
});
});
describe('duration reporting', () => {
it('should report the duration of an operation if it takes more than 1 second by default', async () => {
const castToUpperCase = withLogTrail(
async ({ name }: { name: string }) => {
await sleep(1100);
return name.toUpperCase();
},
{ name: 'castToUpperCase', log: { method: console.log } },
);

// should run like normal
const uppered = await castToUpperCase({ name: 'casey' });
expect(uppered).toEqual('CASEY');

// should have logged input and output
expect(logDebugSpy).toHaveBeenCalledTimes(2);
expect(logDebugSpy).toHaveBeenNthCalledWith(1, 'castToUpperCase.input', {
input: { name: 'casey' },
});
expect(logDebugSpy).toHaveBeenNthCalledWith(2, 'castToUpperCase.output', {
input: { name: 'casey' },
output: ['CASEY'],
duration: expect.stringContaining(' sec'),
});
});
});
});
117 changes: 117 additions & 0 deletions src/logic/withLogTrail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { UnexpectedCodePathError } from '@ehmpathy/error-fns';
import { LogMethod } from 'simple-leveled-log-methods';
import { isAPromise } from 'type-fns';

const noOp = (...input: any) => input;
const omitContext = (...input: any) => input[0]; // standard pattern for args = [input, context]
const roundToHundredths = (num: number) => Math.round(num * 100) / 100; // https://stackoverflow.com/a/14968691/3068233

/**
* enables input output logging and tracing for a method
*
* todo: - add tracing identifier w/ async-context
* todo: - hookup visual tracing w/ external lib (vi...lo...)
* todo: - bundle this with its own logging library which supports scoped logs
*/
export const withLogTrail = <T extends (...args: any[]) => any>(
logic: T,
{
name: declaredName,
durationReportingThresholdInSeconds = 1,
log: logInput,
}: {
/**
* specifies the name of the function, if the function does not have a name assigned already
*/
name?: string;

/**
* enable redacting parts of the input or output from logging
*/
log:
| LogMethod
| {
/**
* specifies the log method to use to log with
*/
method: LogMethod; // TODO: use a logger which leverages async-context to scope all logs created inside of this fn w/ `${name}.progress: ${message}`; at that point, probably stick "inout output tracing" inside of that lib

/**
* what of the input to log
*/
input?: (...value: Parameters<T>) => any;

/**
* what of the output to log
*/
output?: (value: Awaited<ReturnType<T>>) => any;
};

/**
* specifies the threshold after which a duration will be included on the output log
*/
durationReportingThresholdInSeconds?: number;
},
) => {
// cache the name of the function per wrapping
const name: string | null = logic.name || declaredName || null; // use `\\` since `logic.name` returns `""` for anonymous functions

// if no name is identifiable, throw an error here to fail fast
if (!name)
throw new UnexpectedCodePathError(
'could not identify name for wrapped function',
);

// if the name specified does not match the name of the function, throw an error here to fail fast
if (declaredName && name !== declaredName)
throw new UnexpectedCodePathError(
'the natural name of the function is different than the declared name',
{ declaredName, naturalName: name },
);

// extract the log methods
const logMethod: LogMethod =
'method' in logInput ? logInput.method : logInput;
const logInputMethod =
('input' in logInput ? logInput.input : undefined) ?? omitContext;
const logOutputMethod =
('output' in logInput ? logInput.output : undefined) ?? noOp;

// wrap the function
return ((...input: any): any => {
// now log the input
logMethod(`${name}.input`, { input: logInputMethod(...input) });

// begin tracking duration
const startTimeInMilliseconds = new Date().getTime();

// now execute the method
const result = logic(...input);

// define what to do when we have output
const logOutput = (output: Awaited<ReturnType<T>>) => {
const endTimeInMilliseconds = new Date().getTime();
const durationInMilliseconds =
endTimeInMilliseconds - startTimeInMilliseconds;
const durationInSeconds = roundToHundredths(durationInMilliseconds / 1e3); // https://stackoverflow.com/a/53970656/3068233
logMethod(`${name}.output`, {
input: logInputMethod(...input),
output: logOutputMethod(output),
...(durationInSeconds >= durationReportingThresholdInSeconds
? { duration: `${durationInSeconds} sec` } // only include the duration if the threshold was crossed
: {}),
});
};

// if result is a promise, ensure we log after the output resolves
if (isAPromise(result))
return result.then((output) => {
logOutput(output);
return output;
});

// otherwise, its not a promise, so its done, so log now and return the result
logOutput(result);
return result;
}) as T;
};

0 comments on commit ca384aa

Please sign in to comment.