diff --git a/docs/code/functions/writeAVMDebugTrace.md b/docs/code/functions/writeAVMDebugTrace.md index c021970..e1abdc7 100644 --- a/docs/code/functions/writeAVMDebugTrace.md +++ b/docs/code/functions/writeAVMDebugTrace.md @@ -6,7 +6,7 @@ # Function: writeAVMDebugTrace() -> **writeAVMDebugTrace**(`input`): `Promise`\<`void`\> +> **writeAVMDebugTrace**(`input`, `bufferSizeMb`): `Promise`\<`void`\> Generates an AVM debug trace from the provided simulation response and persists it to a file. @@ -16,6 +16,8 @@ Generates an AVM debug trace from the provided simulation response and persists The AVMTracesEventData containing the simulation response and other relevant information. +• **bufferSizeMb**: `number` + ## Returns `Promise`\<`void`\> @@ -36,4 +38,4 @@ console.log(`Trace content: ${result.traceContent}`); ## Defined in -[debugging/writeAVMDebugTrace.ts:20](https://github.com/algorandfoundation/algokit-utils-ts-debug/blob/main/src/debugging/writeAVMDebugTrace.ts#L20) +[debugging/writeAVMDebugTrace.ts:85](https://github.com/algorandfoundation/algokit-utils-ts-debug/blob/main/src/debugging/writeAVMDebugTrace.ts#L85) diff --git a/src/debugging/writeAVMDebugTrace.spec.ts b/src/debugging/writeAVMDebugTrace.spec.ts index fb87ad0..ccee078 100644 --- a/src/debugging/writeAVMDebugTrace.spec.ts +++ b/src/debugging/writeAVMDebugTrace.spec.ts @@ -2,13 +2,15 @@ import { Config, EventType, performAtomicTransactionComposerSimulate } from '@al import { algorandFixture } from '@algorandfoundation/algokit-utils/testing' import { describe, expect, test } from '@jest/globals' import algosdk, { makeEmptyTransactionSigner } from 'algosdk' +import { SimulateResponse } from 'algosdk/dist/types/client/v2/algod/models/types' import * as fs from 'fs/promises' import * as os from 'os' import * as path from 'path' import { DEBUG_TRACES_DIR } from '../constants' import { registerDebugEventHandlers } from '../index' +import { cleanupOldFiles, generateDebugTraceFilename } from './writeAVMDebugTrace' -describe('simulateAndPersistResponse tests', () => { +describe('writeAVMDebugTrace tests', () => { const localnet = algorandFixture() beforeAll(async () => { @@ -45,3 +47,95 @@ describe('simulateAndPersistResponse tests', () => { jest.restoreAllMocks() }) }) + +describe('generateDebugTraceFilename', () => { + const TEST_CASES: Array<[string, object, string]> = [ + [ + 'single payment transaction', + { + lastRound: 1000, + txnGroups: [ + { + txnResults: [{ txnResult: { txn: { txn: { type: 'pay' } } } }], + }, + ], + }, + '1pay', + ], + [ + 'multiple transaction types', + { + lastRound: 1000, + txnGroups: [ + { + txnResults: [ + { txnResult: { txn: { txn: { type: 'pay' } } } }, + { txnResult: { txn: { txn: { type: 'pay' } } } }, + { txnResult: { txn: { txn: { type: 'axfer' } } } }, + { txnResult: { txn: { txn: { type: 'appl' } } } }, + { txnResult: { txn: { txn: { type: 'appl' } } } }, + { txnResult: { txn: { txn: { type: 'appl' } } } }, + ], + }, + ], + }, + '2pay_1axfer_3appl', + ], + ] + + test.each(TEST_CASES)('%s', (testName, mockResponse, expectedPattern) => { + const timestamp = '20230101_120000' + const filename = generateDebugTraceFilename(mockResponse as SimulateResponse, timestamp) + expect(filename).toBe(`${timestamp}_lr${(mockResponse as SimulateResponse).lastRound}_${expectedPattern}.trace.avm.json`) + }) +}) + +describe('cleanupOldFiles', () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'debug-traces-')) + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + test('removes oldest files when buffer size is exceeded', async () => { + // Create test files with different timestamps and sizes + const testFiles = [ + { name: 'old.json', content: 'a'.repeat(1024 * 1024), mtime: new Date('2023-01-01') }, + { name: 'newer.json', content: 'b'.repeat(1024 * 1024), mtime: new Date('2023-01-02') }, + { name: 'newest.json', content: 'c'.repeat(1024 * 1024), mtime: new Date('2023-01-03') }, + ] + + // Create files with specific timestamps + for (const file of testFiles) { + const filePath = path.join(tempDir, file.name) + await fs.writeFile(filePath, file.content) + await fs.utimes(filePath, file.mtime, file.mtime) + } + + // Set buffer size to 2MB (should remove oldest file) + await cleanupOldFiles(2, tempDir) + + // Check remaining files + const remainingFiles = await fs.readdir(tempDir) + expect(remainingFiles).toHaveLength(2) + expect(remainingFiles).toContain('newer.json') + expect(remainingFiles).toContain('newest.json') + expect(remainingFiles).not.toContain('old.json') + }) + + test('does nothing when total size is within buffer limit', async () => { + const content = 'a'.repeat(512 * 1024) // 512KB + await fs.writeFile(path.join(tempDir, 'file1.json'), content) + await fs.writeFile(path.join(tempDir, 'file2.json'), content) + + // Set buffer size to 2MB (files total 1MB, should not remove anything) + await cleanupOldFiles(2, tempDir) + + const remainingFiles = await fs.readdir(tempDir) + expect(remainingFiles).toHaveLength(2) + }) +}) diff --git a/src/debugging/writeAVMDebugTrace.ts b/src/debugging/writeAVMDebugTrace.ts index 2c9a899..07d2ad6 100644 --- a/src/debugging/writeAVMDebugTrace.ts +++ b/src/debugging/writeAVMDebugTrace.ts @@ -1,6 +1,71 @@ import { AVMTracesEventData } from '@algorandfoundation/algokit-utils' +import { SimulateResponse } from 'algosdk/dist/types/client/v2/algod/models/types' import { DEBUG_TRACES_DIR } from '../constants' -import { getProjectRoot, joinPaths, writeToFile } from '../utils' +import { createDirForFilePathIfNotExists, formatTimestampUTC, getProjectRoot, joinPaths, writeToFile } from '../utils' + +type TxnTypeCount = { + type: string + count: number +} + +/** + * Removes old trace files when total size exceeds buffer limit + */ +export async function cleanupOldFiles(bufferSizeMb: number, outputRootDir: string): Promise { + const fs = await import('fs') + const path = await import('path') + + let totalSize = ( + await Promise.all( + (await fs.promises.readdir(outputRootDir)).map(async (file) => (await fs.promises.stat(path.join(outputRootDir, file))).size), + ) + ).reduce((a, b) => a + b, 0) + + if (totalSize > bufferSizeMb * 1024 * 1024) { + const files = await fs.promises.readdir(outputRootDir) + const fileStats = await Promise.all( + files.map(async (file) => { + const stats = await fs.promises.stat(path.join(outputRootDir, file)) + return { file, mtime: stats.mtime, size: stats.size } + }), + ) + + // Sort by modification time (oldest first) + fileStats.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()) + + // Remove oldest files until we're under the buffer size + while (totalSize > bufferSizeMb * 1024 * 1024 && fileStats.length > 0) { + const oldestFile = fileStats.shift()! + totalSize -= oldestFile.size + await fs.promises.unlink(path.join(outputRootDir, oldestFile.file)) + } + } +} + +/** + * Generates a descriptive filename for a debug trace based on transaction types + */ +export function generateDebugTraceFilename(simulateResponse: SimulateResponse, timestamp: string): string { + const txnGroups = simulateResponse.txnGroups + const txnTypesCount = txnGroups.reduce((acc: Map, txnGroup) => { + txnGroup.txnResults.forEach(({ txnResult }) => { + const { type } = txnResult.txn.txn + if (!acc.has(type)) { + acc.set(type, { type, count: 0 }) + } + const entry = acc.get(type)! + entry.count++ + }) + return acc + }, new Map()) + + const txnTypesStr = Array.from(txnTypesCount.values()) + .map(({ count, type }) => `${count}${type}`) + .join('_') + + const lastRound = simulateResponse.lastRound + return `${timestamp}_lr${lastRound}_${txnTypesStr}.trace.avm.json` +} /** * Generates an AVM debug trace from the provided simulation response and persists it to a file. @@ -17,25 +82,17 @@ import { getProjectRoot, joinPaths, writeToFile } from '../utils' * console.log(`Debug trace saved to: ${result.outputPath}`); * console.log(`Trace content: ${result.traceContent}`); */ -export async function writeAVMDebugTrace(input: AVMTracesEventData): Promise { +export async function writeAVMDebugTrace(input: AVMTracesEventData, bufferSizeMb: number): Promise { try { const simulateResponse = input.simulateResponse - const txnGroups = simulateResponse.txnGroups const projectRoot = await getProjectRoot() - - const txnTypesCount = txnGroups.reduce((acc: Record, txnGroup) => { - const txnType = txnGroup.txnResults[0].txnResult.txn.txn.type - acc[txnType] = (acc[txnType] || 0) + 1 - return acc - }, {}) - - const txnTypesStr = Object.entries(txnTypesCount) - .map(([type, count]) => `${count}${type}`) - .join('_') - - const timestamp = new Date().toISOString().replace(/[:.]/g, '') + const timestamp = formatTimestampUTC(new Date()) const outputRootDir = joinPaths(projectRoot, DEBUG_TRACES_DIR) - const outputFilePath = joinPaths(outputRootDir, `${timestamp}_${txnTypesStr}.trace.avm.json`) + const filename = generateDebugTraceFilename(simulateResponse, timestamp) + const outputFilePath = joinPaths(outputRootDir, filename) + + await createDirForFilePathIfNotExists(outputFilePath) + await cleanupOldFiles(bufferSizeMb, outputRootDir) await writeToFile(outputFilePath, JSON.stringify(simulateResponse.get_obj_for_encoding(), null, 2)) } catch (error) { diff --git a/src/index.ts b/src/index.ts index 054f0b0..a1ced2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import { writeAVMDebugTrace, writeTealDebugSourceMaps } from './debugging' */ const registerDebugEventHandlers = (): void => { Config.events.on(EventType.TxnGroupSimulated, async (eventData: AVMTracesEventData) => { - await writeAVMDebugTrace(eventData) + await writeAVMDebugTrace(eventData, Config.traceBufferSizeMb || 256) }) Config.events.on(EventType.AppCompiled, async (data: TealSourcesDebugEventData) => { await writeTealDebugSourceMaps(data) diff --git a/src/utils.ts b/src/utils.ts index 9e89343..5885209 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,13 @@ import { Config } from '@algorandfoundation/algokit-utils' import { DEFAULT_MAX_SEARCH_DEPTH } from './constants' +interface ErrnoException extends Error { + errno?: number + code?: string + path?: string + syscall?: string +} + export const isNode = () => { return typeof process !== 'undefined' && process.versions != null && process.versions.node != null } @@ -13,6 +20,23 @@ export async function writeToFile(filePath: string, content: string): Promise { + const path = await import('path') + const fs = await import('fs') + + try { + await fs.promises.access(path.dirname(filePath)) + } catch (error: unknown) { + const err = error as ErrnoException + + if (err.code === 'ENOENT') { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }) + } else { + throw err + } + } +} + export async function getProjectRoot(): Promise { const projectRoot = Config.projectRoot @@ -52,3 +76,20 @@ export function joinPaths(...parts: string[]): string { const separator = typeof process !== 'undefined' && process.platform === 'win32' ? '\\' : '/' return parts.join(separator).replace(/\/+/g, separator) } + +/** + * Formats a date to YYYYMMDD_HHMMSS in UTC, equivalent to algokit-utils-py format: + * datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S") + */ +export function formatTimestampUTC(date: Date): string { + // Get UTC components + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') // Months are zero-based + const day = String(date.getUTCDate()).padStart(2, '0') + const hours = String(date.getUTCHours()).padStart(2, '0') + const minutes = String(date.getUTCMinutes()).padStart(2, '0') + const seconds = String(date.getUTCSeconds()).padStart(2, '0') + + // Format the datetime string + return `${year}${month}${day}_${hours}${minutes}${seconds}` +}