diff --git a/package.json b/package.json index 38e4e9c..e04a5ae 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/AsOfGlossary.ts b/src/AsOfGlossary.ts deleted file mode 100644 index d2e8834..0000000 --- a/src/AsOfGlossary.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * the shape of a type which is declared as part of a particular glossary - */ -export interface OfGlossary { - /** - * a metadata identifier for the glossary this domain object is part of - */ - _dglo: G; -} - -/** - * .what = extends the decorated type to specify which glossary it is a part of - * .why = - * - explicitly declare types as part of a domain-glossary - * - for example, assign "" - * - ensure that a simple type alias does not get reduced by typescript - * - e.g., `type UniDate = string` would get introspected as just `string` - * - but., `type UniDate = AsOfGlossary` will get introspected as `UniDate` - */ -export type AsOfGlossary< - T, - G extends string, - /** - * whether the _dglo annotation is required - * - * usecase - * - allows requirement of having gone through an explicit type check before attribute can be assigned - * - true by default, for pit of success, fail-fast safety - * - * example - * ```ts - * type TimestampWithReq = OfGlossary - * type TimestampWithout = OfGlossary - * - * const isOfTimestamp = (input: string): input is TimestampWithReq => {...}; - * - * const input = '2024, Nov 1'; - * const attemptOne: TimestampWithReq = // 🛑 fails as `string is not assignable to TimestampWithReq` - * if (isOfTimestamp(input)) { - * const attemptTwo: TimestampWithReq = input; // ✅ passes as the domain check confirmed it is the correct shape - * } - * const attemptThree: TimestampWithoutReq = input; // ✅ passes as there was no requirement to ensure it passed through the domain check - * ``` - */ - R = true, -> = R extends true ? T & OfGlossary : T & Partial>; diff --git a/src/domain/Procedure.ts b/src/domain/Procedure.ts new file mode 100644 index 0000000..b68a598 --- /dev/null +++ b/src/domain/Procedure.ts @@ -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 = Parameters[0]; + +/** + * extracts the context::Type of a procedure + */ +export type ProcedureContext = Parameters[1]; + +/** + * extracts the output::Type of a procedure + */ +export type ProcedureOutput = ReturnType; diff --git a/src/index.ts b/src/index.ts index dacba0c..c26d48c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -export * from './AsOfGlossary'; +export * from './logic/withLogTrail'; +export * from './logic/getResourceNameFromFileName'; diff --git a/src/logic/asDomainProcedure.ts b/src/logic/asDomainProcedure.ts new file mode 100644 index 0000000..20a62c6 --- /dev/null +++ b/src/logic/asDomainProcedure.ts @@ -0,0 +1,3 @@ +import { withLogTrail } from './withLogTrail'; + +export const asDomainProcedure = withLogTrail; // todo: also, ask for what, why, when, how.idea diff --git a/src/logic/getResourceNameFromFileName.ts b/src/logic/getResourceNameFromFileName.ts new file mode 100644 index 0000000..c9e1600 --- /dev/null +++ b/src/logic/getResourceNameFromFileName.ts @@ -0,0 +1,2 @@ +export const getResourceNameFromFileName = (fileName: string) => + fileName.split('/').slice(-1)[0]!.split('.').slice(0, -1).join('.'); diff --git a/src/logic/withLogTrail.test.ts b/src/logic/withLogTrail.test.ts new file mode 100644 index 0000000..c908270 --- /dev/null +++ b/src/logic/withLogTrail.test.ts @@ -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'), + }); + }); + }); +}); diff --git a/src/logic/withLogTrail.ts b/src/logic/withLogTrail.ts new file mode 100644 index 0000000..144f286 --- /dev/null +++ b/src/logic/withLogTrail.ts @@ -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 = 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) => any; + + /** + * what of the output to log + */ + output?: (value: Awaited>) => 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>) => { + 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; +};