Skip to content

Commit

Permalink
feat: Code block recording
Browse files Browse the repository at this point in the history
  • Loading branch information
zermelo-wisen authored and dividedmind committed Apr 3, 2024
1 parent 68fe99a commit 9e1048a
Show file tree
Hide file tree
Showing 12 changed files with 295 additions and 10 deletions.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ to your tool invocation:
$ npx appmap-node yarn jest
$ npx appmap-node npx ts-node foo.ts

## Code Block Recording

You can run appmap-node and use the exposed API to record snippets
of interest.

$ npx appmap-node foo.js

foo.js
```JavaScript
const { record } = require("appmap-node");

const appmap = record(() => {
hello("world");
});
// You can consume the details of the appmap object
console.log("# of events: ", appmap?.events.length);
```
## Configuration
You can create `appmap.yml` config file; if not found, a default one will be created:
Expand Down
15 changes: 14 additions & 1 deletion src/Recording.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert";
import { renameSync, rmSync } from "node:fs";
import { readFileSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { inspect } from "node:util";

Expand All @@ -11,6 +11,7 @@ import { makeCallEvent, makeExceptionEvent, makeReturnEvent } from "./event";
import { defaultMetadata } from "./metadata";
import type { FunctionInfo } from "./registry";
import compactObject from "./util/compactObject";
import { shouldRecord } from "./recorderControl";

export default class Recording {
constructor(type: AppMap.RecorderType, recorder: string, ...names: string[]) {
Expand Down Expand Up @@ -172,6 +173,13 @@ export default class Recording {
}

private emit(event: unknown) {
// Check here if we should record instead of requiring each
// possible hook to check it.
// This is also checked in recorder.record() to prevent
// unnecessary event object creation. Checking this inside hooks,
// (http, sqlite, pg, mysql, ...) will save some CPU cycles but
// will complicate their code.
if (!shouldRecord()) return;
assert(this.stream);
this.stream.emit(event);
}
Expand Down Expand Up @@ -206,6 +214,11 @@ export default class Recording {
get running(): boolean {
return !!this.stream;
}

readAppMap(): AppMap.AppMap {
assert(!this.running);
return JSON.parse(readFileSync(this.path, "utf8")) as AppMap.AppMap;
}
}

export const writtenAppMaps: string[] = [];
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/recorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type * as AppMap from "../AppMap";
import Recording from "../Recording";
import { resetObjectIds } from "../parameter";
import * as recorder from "../recorder";
import { pauseRecorder, resumeRecorder } from "../recorderPause";
import { pauseRecorder, resumeRecorder } from "../recorderControl";
import { getTime } from "../util/getTime";
import { createTestFn } from "./helpers";

Expand Down
52 changes: 52 additions & 0 deletions src/facade.ts
Original file line number Diff line number Diff line change
@@ -1 +1,53 @@
import { isPromise } from "node:util/types";

import type * as AppMap from "./AppMap";
import { exceptionMetadata } from "./metadata";

import { recording, start } from "./recorder";
import {
disableGlobalRecording,
startCodeBlockRecording,
stopCodeBlockRecording,
} from "./recorderControl";
import Recording from "./Recording";

// Since _this_ module is loaded, we'll do code block recording only.
recording.abandon();
disableGlobalRecording();

function isInstrumented() {
return "AppMapRecordHook" in global;
}

type NotPromise<T> = T extends Promise<unknown> ? never : T;

export function record<T>(block: () => NotPromise<T>): AppMap.AppMap | undefined;
export function record<T>(block: () => Promise<T>): Promise<AppMap.AppMap | undefined>;

export function record(
block: () => unknown,
): AppMap.AppMap | Promise<AppMap.AppMap | undefined> | undefined {
if (!isInstrumented())
throw Error("Code is not instrumented. Please run the project with appmap-node.");

start(new Recording("block", "block", new Date().toISOString()));
startCodeBlockRecording();
try {
const result = block();
if (isPromise(result)) return result.then(() => finishRecording(), finishRecording);
} catch (exn) {
return finishRecording(exn);
}
return finishRecording();
}

function finishRecording(exn?: unknown): AppMap.AppMap | undefined {
stopCodeBlockRecording();
if (!recording.finish()) return;

const appmap = recording.readAppMap();
if (exn && appmap.metadata) appmap.metadata.exception = exceptionMetadata(exn);
return appmap;
}

export * as AppMap from "./AppMap";
2 changes: 1 addition & 1 deletion src/parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ClientRequest, IncomingMessage, ServerResponse } from "node:http";
import { format, inspect, isDeepStrictEqual } from "node:util";

import type * as AppMap from "./AppMap";
import { pauseRecorder, resumeRecorder } from "./recorderPause";
import { pauseRecorder, resumeRecorder } from "./recorderControl";
import compactObject from "./util/compactObject";

export function parameter(value: unknown): AppMap.Parameter {
Expand Down
4 changes: 2 additions & 2 deletions src/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Recording, { writtenAppMaps } from "./Recording";
import { makeExceptionEvent, makeReturnEvent } from "./event";
import { info } from "./message";
import { getClass, objectId } from "./parameter";
import { recorderPaused } from "./recorderPause";
import { shouldRecord } from "./recorderControl";
import { FunctionInfo } from "./registry";
import commonPathPrefix from "./util/commonPathPrefix";
import { getTime } from "./util/getTime";
Expand All @@ -19,7 +19,7 @@ export function record<This, Return>(
args: unknown[],
funInfo: FunctionInfo,
): Return {
if (!recording.running || recorderPaused()) return fun.apply(this, args);
if (!recording.running || !shouldRecord()) return fun.apply(this, args);

const call = recording.functionCall(
funInfo,
Expand Down
18 changes: 18 additions & 0 deletions src/recorderControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Not put into recorder.ts to prevent circular dependency
let _recorderPaused = false;
export const pauseRecorder = () => (_recorderPaused = true);
export const resumeRecorder = () => (_recorderPaused = false);
export const recorderPaused = () => _recorderPaused;

let _globalRecordingDisabled = false;
export const disableGlobalRecording = () => (_globalRecordingDisabled = true);
export const enableGlobalRecording = () => (_globalRecordingDisabled = false);
export const globalRecordingDisabled = () => _globalRecordingDisabled;

let _codeBlockRecordingActive = false;
export const startCodeBlockRecording = () => (_codeBlockRecordingActive = true);
export const stopCodeBlockRecording = () => (_codeBlockRecordingActive = false);
export const codeBlockRecordingActive = () => _codeBlockRecordingActive;

export const shouldRecord = () =>
!recorderPaused() && (!globalRecordingDisabled() || codeBlockRecordingActive());
5 changes: 0 additions & 5 deletions src/recorderPause.ts

This file was deleted.

140 changes: 140 additions & 0 deletions test/__snapshots__/codeBlock.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`mapping code block recording 1`] = `
{
"./tmp/appmap/block/<timestamp 0>.appmap.json": {
"classMap": [
{
"children": [
{
"children": [
{
"location": "index.js:5",
"name": "hello",
"static": true,
"type": "function",
},
],
"name": "index",
"type": "class",
},
],
"name": "codeBlock",
"type": "package",
},
],
"events": [
{
"defined_class": "index",
"event": "call",
"id": 1,
"lineno": 5,
"method_id": "hello",
"parameters": [
{
"class": "String",
"name": "message",
"value": "'world'",
},
],
"path": "index.js",
"static": true,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"id": 2,
"parent_id": 1,
"thread_id": 0,
},
],
"metadata": {
"app": "codeBlock",
"client": {
"name": "appmap-node",
"url": "https://github.com/getappmap/appmap-node",
"version": "test node-appmap version",
},
"language": {
"engine": "Node.js",
"name": "javascript",
"version": "test node version",
},
"name": "<timestamp 0>",
"recorder": {
"name": "block",
"type": "block",
},
},
"version": "1.12",
},
"./tmp/appmap/block/<timestamp 1>.appmap.json": {
"classMap": [
{
"children": [
{
"children": [
{
"location": "index.js:5",
"name": "hello",
"static": true,
"type": "function",
},
],
"name": "index",
"type": "class",
},
],
"name": "codeBlock",
"type": "package",
},
],
"events": [
{
"defined_class": "index",
"event": "call",
"id": 1,
"lineno": 5,
"method_id": "hello",
"parameters": [
{
"class": "String",
"name": "message",
"value": "'world async'",
},
],
"path": "index.js",
"static": true,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"id": 2,
"parent_id": 1,
"thread_id": 0,
},
],
"metadata": {
"app": "codeBlock",
"client": {
"name": "appmap-node",
"url": "https://github.com/getappmap/appmap-node",
"version": "test node-appmap version",
},
"language": {
"engine": "Node.js",
"name": "javascript",
"version": "test node version",
},
"name": "<timestamp 1>",
"recorder": {
"name": "block",
"type": "block",
},
},
"version": "1.12",
},
}
`;
16 changes: 16 additions & 0 deletions test/codeBlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { spawnSync } from "node:child_process";

import { integrationTest, readAppmaps, resolveTarget, runAppmapNode } from "./helpers";

integrationTest("mapping code block recording", () => {
expect(runAppmapNode("index.js").status).toBe(0);
expect(readAppmaps()).toMatchSnapshot();
});

integrationTest("throws when not run with appmap-node", () => {
const testDir = resolveTarget();
const result = spawnSync(process.argv[0], ["index.js"], { cwd: testDir });
expect(result.stderr?.toString()).toContain(
"Code is not instrumented. Please run the project with appmap-node.",
);
});
2 changes: 2 additions & 0 deletions test/codeBlock/appmap.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
name: codeBlock
appmap_dir: tmp/appmap
31 changes: 31 additions & 0 deletions test/codeBlock/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const assert = require("node:assert");
const { record } = require("../../dist/facade");

function hello(message) {
console.log("hello", message);
}

async function main() {
hello("darkness my old friend");

const appmap1 = record(() => {
hello("world");
});

hello("123");

assert(appmap1.events.filter((e) => e.event == "call").length == 1);

const appmap2 = await record(async () => {
hello("world async");
});

hello("x");
hello("y");
hello("z");

assert(appmap2.events.filter((e) => e.event == "call").length == 1);
}

main();

0 comments on commit 9e1048a

Please sign in to comment.