diff --git a/FEATURE_RECOMMENDATIONS.md b/FEATURE_RECOMMENDATIONS.md new file mode 100644 index 0000000..2b65803 --- /dev/null +++ b/FEATURE_RECOMMENDATIONS.md @@ -0,0 +1,477 @@ +# Timer Ninja - Feature Recommendations & Enhancement Analysis + +> **Document Type**: Solution Architecture Analysis +> **Project**: Timer Ninja - A Lightweight Java Method Timing Library +> **Date**: January 31, 2026 +> **Baseline Principle**: Minimal, self-contained, easy to use, no external provider dependencies + +Analyzer: Claude Opus 4.5 +--- + +## Executive Summary + +Timer Ninja is a well-designed AOP-based library for measuring Java method execution times with hierarchical call tree visualization. After reviewing the current capabilities, I've identified enhancement opportunities that align with the project's core philosophy of **minimalism** and **self-containment**. + +The recommendations are organized into three tiers based on implementation effort and user impact: +1. **Quick Enhancements** - High value, low complexity features +2. **Core Improvements** - Medium complexity features that enhance core functionality +3. **Future Considerations** - Advanced features for long-term roadmap + +--- + +## Current Capabilities Summary + +| Feature | Status | +|---------|--------| +| `@TimerNinjaTracker` annotation | ✅ | +| Hierarchical call tree output | ✅ | +| Configurable time units (SECONDS, MILLIS, MICROS) | ✅ | +| Argument logging (`includeArgs`) | ✅ | +| Threshold filtering | ✅ | +| Enable/disable toggle per method | ✅ | +| System.out fallback logging | ✅ | +| Block-based measurement (`TimerNinjaBlock`) | ✅ | + +--- + +## Tier 1: Quick Enhancements + +### 1.1 🎯 **Output Format Templates** + +**Problem**: Current output format is fixed. Users may need different formats for different environments (development vs production logging). + +**Proposed Solution**: Add pre-defined output format templates via configuration. + +```java +TimerNinjaConfiguration.getInstance().setOutputFormat(OutputFormat.COMPACT); + +// Available formats: +// - TREE (default): Current hierarchical tree format +// - COMPACT: Single line per trace, essential info only +// - VERBOSE: Includes full package names and additional metadata +``` + +**Sample COMPACT output**: +``` +[TimerNinja] c9ff..65 | getUserById(int) -> 554ms [findUserById: 251ms] +``` + +**Complexity**: Low +**Impact**: High - Reduces log noise in production environments + +--- + +### 1.2 📊 **Execution Summary Mode** + +**Problem**: For batch operations, the detailed tree can be overwhelming. Users need a summary view. + +**Proposed Solution**: Add `@TimerNinjaTracker(summaryOnly = true)` option. + +```java +@TimerNinjaTracker(summaryOnly = true) +public void processBatch(List orders) { + orders.forEach(this::processOrder); +} +``` + +**Output**: +``` +Timer Ninja Summary - processBatch(List) +├── Total time: 5,230ms +├── Child calls: 150 +├── Slowest child: processOrder (Order#47) - 250ms +└── Fastest child: processOrder (Order#12) - 45ms +``` + +**Complexity**: Low +**Impact**: Medium - Better UX for batch/loop scenarios + +--- + +### 1.3 🔇 **Silent Mode with On-Demand Log Retrieval** + +**Problem**: Users may want to capture traces without immediate logging, then retrieve them later (e.g., only log on error). + +**Proposed Solution**: Add silent capture mode with programmatic retrieval. + +```java +// Enable silent mode globally +TimerNinjaConfiguration.getInstance().setSilentMode(true); + +// In error handling code +try { + processOrder(order); +} catch (Exception e) { + // Get the last trace on this thread + String lastTrace = TimerNinjaConfiguration.getInstance().getLastTrace(); + logger.error("Order processing failed. Trace: {}", lastTrace, e); +} +``` + +**Complexity**: Low-Medium +**Impact**: High - Enables conditional logging, reduces log volume + +--- + +### 1.4 ⏸️ **Conditional Activation via Environment Variable** + +**Problem**: Users need to enable/disable Timer Ninja without code changes (e.g., for production deployments). + +**Proposed Solution**: Respect an environment variable for global activation. + +```bash +# Disable all tracking +export TIMER_NINJA_ENABLED=false + +# Enable with sampling +export TIMER_NINJA_ENABLED=true +export TIMER_NINJA_SAMPLE_RATE=0.1 +``` + +```java +// Code still has annotations, but they're no-op when disabled +@TimerNinjaTracker +public void process() { + // Aspect checks env var, skips tracking if disabled +} +``` + +**Complexity**: Low +**Impact**: High - Essential for production deployments + +--- + +## Tier 2: Core Improvements + +### 2.1 🚨 **Exception Correlation** + +**Problem**: When a method throws an exception, the trace ends abruptly without clear indication of failure. + +**Proposed Solution**: Capture and display exception information in the trace. + +```java +@TimerNinjaTracker(trackException = true) // default: true +public void processPayment(User user, int amount) { + // May throw PaymentException +} +``` + +**Output on exception**: +``` +{===== Start of trace context id: c9ffeb39-3457-48d4-9b73-9ffe7d612165 =====} +public void processPayment(User user, int amount) - 127ms ❌ FAILED + |-- Exception: PaymentException: Insufficient funds + |-- Stacktrace: PaymentService.java:45 → AccountService.java:112 +{====== End of trace context id: c9ffeb39-3457-48d4-9b73-9ffe7d612165 ======} +``` + +**Complexity**: Low-Medium +**Impact**: High - Critical for debugging production issues + +--- + +### 2.2 📈 **Minimal In-Memory Statistics** + +**Problem**: No way to track performance trends over multiple invocations. + +**Proposed Solution**: Add lightweight, in-memory statistics tracking with bounded memory usage. + +```java +// Enable stats (off by default to maintain minimalism) +TimerNinjaConfiguration config = TimerNinjaConfiguration.getInstance(); +config.setEnableStatistics(true); +config.setStatisticsBufferSize(100); // Keep last 100 samples per method + +// Later, retrieve stats +MethodStats stats = config.getStatistics("processPayment"); +System.out.println("Avg: " + stats.getAverage() + "ms"); +System.out.println("p95: " + stats.getPercentile(95) + "ms"); +System.out.println("Count: " + stats.getInvocationCount()); + +// Or print a summary report +config.printStatisticsReport(); +``` + +**Statistics Report Output**: +``` +===== Timer Ninja Statistics Report ===== +Method | Count | Avg | p50 | p95 | Max +------------------------------------|-------|-------|-------|-------|------ +processPayment(User, int) | 1,234 | 156ms | 142ms | 289ms | 512ms +findUser(int) | 3,456 | 45ms | 38ms | 102ms | 234ms +===== End of Report ===== +``` + +**Complexity**: Medium +**Impact**: High - Enables performance trending without external tools + +--- + +### 2.3 🔍 **Return Value Logging** + +**Problem**: Argument logging exists, but return value logging is not available. + +**Proposed Solution**: Add `includeReturnValue` parameter. + +```java +@TimerNinjaTracker(includeArgs = true, includeReturnValue = true) +public User findUser(int userId) { + return userRepository.findById(userId); +} +``` + +**Output**: +``` +public User findUser(int userId) - Args: [userId={123}] - Return: User{id=123, name='John'} - 45ms +``` + +**Complexity**: Low +**Impact**: Medium - Completes the input/output logging story + +--- + +### 2.4 🎛️ **Method Depth Limiting** + +**Problem**: Deep recursive calls or complex call stacks can produce excessively long traces. + +**Proposed Solution**: Add configurable depth limits. + +```java +// Global setting +TimerNinjaConfiguration.getInstance().setMaxTraceDepth(5); + +// Or per-method override +@TimerNinjaTracker(maxDepth = 3) +public void deepRecursiveMethod() { + // Only tracks 3 levels deep from this entry point +} +``` + +**Output with depth=3**: +``` +public void process() - 500ms + |-- public void step1() - 200ms + |-- public void step1a() - 100ms + |-- [... 3 more levels truncated ...] + |-- public void step2() - 300ms +``` + +**Complexity**: Medium +**Impact**: Medium - Prevents trace explosion + +--- + +### 2.5 🏷️ **Custom Labels and Grouping** + +**Problem**: Method signatures can be long and hard to scan. No way to categorize/group methods. + +**Proposed Solution**: Add optional labels for cleaner output and logical grouping. + +```java +@TimerNinjaTracker(label = "DB_READ") +public User findUser(int userId) { } + +@TimerNinjaTracker(label = "DB_WRITE") +public void saveUser(User user) { } + +@TimerNinjaTracker(label = "EXTERNAL_API") +public PaymentResult chargeCard(Card card, Amount amount) { } +``` + +**Output with labels**: +``` +[DB_READ] findUser(int) - 45ms + |-- [EXTERNAL_API] chargeCard(Card, Amount) - 320ms + |-- [DB_WRITE] saveUser(User) - 28ms +``` + +**Benefits**: +- Easier visual scanning +- Can filter statistics by label +- Group related operations + +**Complexity**: Low +**Impact**: Medium - Improves readability and analysis + +--- + +## Tier 3: Future Considerations + +### 3.1 🔀 **Thread Context Propagation** + +**Problem**: When using ExecutorService or CompletableFuture, trace context is lost in new threads. + +**Proposed Solution**: Provide utilities for context propagation. + +```java +// Wrap executor to propagate context +ExecutorService executor = TimerNinjaContext.wrap(Executors.newFixedThreadPool(4)); + +// Or wrap individual runnables +CompletableFuture.runAsync( + TimerNinjaContext.wrap(() -> processAsync()), + executor +); +``` + +**Complexity**: High +**Impact**: High - Critical for async applications + +--- + +### 3.2 📄 **Trace Export to File** + +**Problem**: For post-mortem analysis, users may want to save traces to files. + +**Proposed Solution**: Add file-based trace export (self-contained, no external dependencies). + +```java +TimerNinjaConfiguration config = TimerNinjaConfiguration.getInstance(); +config.setExportToFile(true); +config.setExportDirectory("/var/log/timerninja/"); +config.setExportFormat(ExportFormat.JSON); // or TEXT +config.setMaxFileSize("10MB"); // Rotation +``` + +**Complexity**: Medium +**Impact**: Medium - Useful for production debugging + +--- + +### 3.3 💾 **Memory Delta Tracking** + +**Problem**: Performance issues sometimes correlate with memory usage. + +**Proposed Solution**: Optional memory delta measurement using Java's Runtime. + +```java +@TimerNinjaTracker(trackMemory = true) +public void loadBigData() { + // Load large dataset +} +``` + +**Output**: +``` +public void loadBigData() - 2,340ms | Memory: +45.2MB +``` + +**Note**: Uses `Runtime.getRuntime().totalMemory() - freeMemory()` - no external dependencies. + +**Complexity**: Low +**Impact**: Low-Medium - Niche use case + +--- + +### 3.4 🎚️ **Sampling Mode for Production** + +**Problem**: Tracking every method call in high-throughput production systems may be too expensive. + +**Proposed Solution**: Add configurable sampling rate. + +```java +// Global sampling: track only 10% of method calls +TimerNinjaConfiguration.getInstance().setSamplingRate(0.1); + +// Per-method override +@TimerNinjaTracker(samplingRate = 0.05) // 5% for this hot method +public void highFrequencyMethod() { } +``` + +**Complexity**: Low +**Impact**: High - Enables production usage with minimal overhead + +--- + +## Implementation Roadmap Recommendation + +Based on the principle of **maximum value with minimal complexity**, here's the suggested implementation order: + +### Phase 1: Essential Production-Readiness (v1.3) +| Priority | Feature | Effort | Value | +|----------|---------|--------|-------| +| 1 | Environment Variable Activation (1.4) | Low | 🔥🔥🔥 | +| 2 | Exception Correlation (2.1) | Low-Med | 🔥🔥🔥 | +| 3 | Silent Mode with Retrieval (1.3) | Low-Med | 🔥🔥 | + +### Phase 2: Enhanced Usability (v1.4) +| Priority | Feature | Effort | Value | +|----------|---------|--------|-------| +| 4 | Return Value Logging (2.3) | Low | 🔥🔥 | +| 5 | Custom Labels (2.5) | Low | 🔥🔥 | +| 6 | Output Format Templates (1.1) | Low | 🔥🔥 | + +### Phase 3: Advanced Analytics (v1.5) +| Priority | Feature | Effort | Value | +|----------|---------|--------|-------| +| 7 | Minimal In-Memory Statistics (2.2) | Medium | 🔥🔥🔥 | +| 8 | Method Depth Limiting (2.4) | Medium | 🔥 | +| 9 | Sampling Mode (3.4) | Low | 🔥🔥 | + +### Phase 4: Enterprise Features (v2.0) +| Priority | Feature | Effort | Value | +|----------|---------|--------|-------| +| 10 | Thread Context Propagation (3.1) | High | 🔥🔥 | +| 11 | Trace Export to File (3.2) | Medium | 🔥 | +| 12 | Memory Delta Tracking (3.3) | Low | 🔥 | + +--- + +## Design Principles for New Features + +All new features should adhere to these principles: + +1. **🔒 Off by Default**: New features should be opt-in to maintain backward compatibility and minimalism +2. **🎯 Zero External Dependencies**: Only use Java standard library, AspectJ, and SLF4J +3. **⚡ Minimal Runtime Overhead**: Features should have negligible performance impact when disabled +4. **🔧 Configurable**: Both annotation-level and global configuration options +5. **📖 Well Documented**: Clear examples in README and Javadoc +6. **🧪 Testable**: Unit tests for all new functionality + +--- + +## Configuration API Design Preview + +```java +// Proposed unified configuration API +TimerNinjaConfiguration config = TimerNinjaConfiguration.getInstance(); + +// Activation control +config.setEnabled(true); // Master switch +config.setSamplingRate(1.0); // 100% by default + +// Output control +config.setOutputFormat(OutputFormat.TREE); // TREE | COMPACT | VERBOSE +config.setSilentMode(false); // Enable on-demand retrieval +config.toggleSystemOutLog(false); // Use SLF4J only + +// Statistics (disabled by default) +config.setStatisticsEnabled(false); +config.setStatisticsBufferSize(100); + +// Limits +config.setMaxTraceDepth(10); // Prevent trace explosion + +// Filtering +config.addExcludePattern("*.toString"); // Exclude specific patterns + +// File export (disabled by default) +config.setFileExportEnabled(false); +config.setFileExportPath("/var/log/timerninja/"); +``` + +--- + +## Conclusion + +Timer Ninja has a solid foundation. The recommended enhancements focus on: + +1. **Production readiness** - Environment-based control, exception tracking, sampling +2. **Developer experience** - Better output formats, labels, statistics +3. **Scalability** - Depth limits, file export, async support + +All recommendations maintain the core philosophy of being **minimal**, **self-contained**, and **easy to use** without requiring external providers or complex configurations. + +--- + +*This analysis was prepared by evaluating the current Timer Ninja v1.2.0 capabilities against common performance debugging needs in Java enterprise applications.* diff --git a/STATISTICS_FEATURE_PLAN.md b/STATISTICS_FEATURE_PLAN.md new file mode 100644 index 0000000..f1eb09d --- /dev/null +++ b/STATISTICS_FEATURE_PLAN.md @@ -0,0 +1,606 @@ +# Minimal In-Memory Statistics Tracking - Implementation Plan (Revised) + +> **Feature**: In-Memory Statistics with p50, p90, p95, avg execution time tracking +> **Version Target**: v1.3.0 +> **Date**: January 31, 2026 +> **Revision**: 2 - Incorporates user feedback + +--- + +## Goal Description + +Add capability for Timer Ninja to collect and aggregate execution time statistics across multiple method invocations. Users can enable statistics tracking, and later retrieve a report containing: + +- **Invocation count** per method +- **Average** execution time (calculated on-demand) +- **Percentile metrics**: p50, p90, p95 +- **Min/Max** execution time +- **Threshold statistics**: count of executions within/exceeding threshold + +The feature must: +1. ✅ **Not break existing implementation** - Statistics tracking is opt-in +2. ✅ **Minimize performance overhead** - Calculations done on report extraction only +3. ✅ **Remain self-contained** - No external dependencies (HTML uses CDN for styling) + +--- + +## User Feedback Incorporated + +| Feedback | Resolution | +|----------|------------| +| Support `TimerNinjaBlock` capture | ✅ Added statistics recording in `TimerNinjaBlock.measure()` | +| Unique method identifier | ✅ Added `trackerId` to annotation, uses `class.method` as default key | +| Threshold tracking | ✅ Added threshold config and exceed/within counts to `MethodStatistics` | +| HTML report with hierarchical view | ✅ Added self-contained HTML with Bootstrap CDN, collapsible parent-child | +| Rename config method | ✅ Changed to `enableStatisticsReporting(boolean)` | +| Binary report for REST download | ✅ Report methods return `byte[]` or `String` | +| On-demand calculation | ✅ All percentile/avg calculations done only on report extraction | +| Shortened signature configurable | ✅ Added `setUseFullMethodSignature(boolean)` config option | + +--- + +## Architecture Overview + +```mermaid +flowchart TB + subgraph Existing["Existing Components"] + A["@TimerNinjaTracker"] --> B["TimeTrackingAspect.aj"] + X["TimerNinjaBlock.measure()"] + B --> C["TrackerItemContext"] + end + + subgraph New["New Components"] + E["StatisticsCollector"] + F["MethodStatistics"] + G["StatisticsReportGenerator"] + end + + subgraph Config["Configuration"] + H["TimerNinjaConfiguration"] + end + + B -->|"After timing captured"| E + X -->|"After block measured"| E + E -->|"Aggregates per tracker ID"| F + H -->|"Enable/Disable, Buffer Size"| E + E -->|"Generate report"| G +``` + +--- + +## Proposed Changes + +### Core Statistics Infrastructure + +#### [NEW] [MethodStatistics.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/main/aspectj/io/github/thanglequoc/timerninja/MethodStatistics.java) + +Statistics holder for a single tracker (identified by unique `trackerId`). + +```java +public class MethodStatistics { + private final String trackerId; // Unique ID (user-defined or auto-generated) + private final String methodSignature; // e.g., "processPayment(User, int)" + private final String className; // e.g., "com.example.PaymentService" + private final List executionTimes; // Circular buffer (FIFO eviction) + private final int maxBufferSize; + + // Threshold tracking + private int thresholdMs = -1; // Configured threshold (-1 = not set) + private int thresholdExceededCount = 0; + private int thresholdWithinCount = 0; + + // Parent-child hierarchy for nested calls + private String parentTrackerId; // null if root + private List childTrackerIds; + + // Methods (calculations done on-demand, not on record): + // - recordExecution(long timeInMillis, boolean exceededThreshold) + // - getInvocationCount() + // - calculateAverage() // Computed on-demand + // - calculatePercentile(int) // Computed on-demand + // - getMin(), getMax() + // - getThresholdExceededCount(), getThresholdWithinCount() + // - reset() +} +``` + +**Key Design Decisions**: +- **Unique `trackerId`**: Defaults to `className.methodName` but can be overridden via annotation +- **FIFO eviction**: When buffer is full, oldest entries are discarded +- **On-demand calculations**: `calculateAverage()` and `calculatePercentile()` compute on call, not on record +- **Thread-safe**: Uses `synchronized` blocks for recording + +--- + +#### [NEW] [StatisticsCollector.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/main/aspectj/io/github/thanglequoc/timerninja/StatisticsCollector.java) + +Singleton managing statistics collection for all tracked methods. + +```java +public class StatisticsCollector { + private static StatisticsCollector instance; + + // Tracker ID -> Statistics + private final ConcurrentHashMap statisticsMap; + private int maxBufferSizePerMethod = 1000; + + // Methods: + // - recordExecution(String trackerId, String className, String methodSignature, + // long executionTimeMs, int thresholdMs, String parentTrackerId) + // - getStatistics(String trackerId) + // - getAllStatistics() + // - reset() + // - setMaxBufferSize(int size) +} +``` + +--- + +#### [NEW] [StatisticsReportGenerator.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/main/aspectj/io/github/thanglequoc/timerninja/StatisticsReportGenerator.java) + +Utility class for generating formatted statistics reports. + +```java +public class StatisticsReportGenerator { + // Report generation methods (return String or byte[]): + // - generateTextReport(Map stats) + // - generateJsonReport(Map stats) + // - generateHtmlReport(Map stats) + + // Binary export for REST download: + // - getReportAsBytes(String format) // "text", "json", "html" +} +``` + +--- + +### Report Formats + +#### Text Report +``` +===== Timer Ninja Statistics Report ===== +Generated: 2026-01-31T23:00:00Z +Total tracked methods: 3 + +Tracker ID | Count | Avg | p50 | p90 | p95 | Min | Max | Threshold↑ | Threshold↓ +------------------------------------------|-------|--------|--------|--------|--------|--------|--------|------------|------------ +PaymentService.processPayment | 1,234 | 156ms | 142ms | 289ms | 312ms | 45ms | 512ms | 23 | 1,211 + └─ AccountService.changeAmount | 1,234 | 89ms | 76ms | 145ms | 167ms | 23ms | 289ms | 5 | 1,229 +UserService.findUser | 3,456 | 45ms | 38ms | 102ms | 118ms | 12ms | 234ms | - | - + +===== End of Report ===== +``` + +#### JSON Report +```json +{ + "generatedAt": "2026-01-31T23:00:00Z", + "totalMethods": 3, + "trackers": [ + { + "trackerId": "PaymentService.processPayment", + "className": "com.example.PaymentService", + "methodSignature": "processPayment(User, int)", + "count": 1234, + "avgMs": 156, + "p50Ms": 142, + "p90Ms": 289, + "p95Ms": 312, + "minMs": 45, + "maxMs": 512, + "thresholdMs": 200, + "thresholdExceeded": 23, + "thresholdWithin": 1211, + "children": [ + { + "trackerId": "AccountService.changeAmount", + ... + } + ] + } + ] +} +``` + +#### HTML Report (Self-Contained) + +Self-contained HTML file with **Bootstrap 5 via CDN** for styling. Features: +- Responsive table layout +- Collapsible rows for parent-child hierarchy (using Bootstrap accordion/collapse) +- Color-coded threshold exceed counts +- Download-ready (can be served as binary via REST) + +```html + + + + + + Timer Ninja Statistics Report + + + + +
+

🥷 Timer Ninja Statistics Report

+

Generated: 2026-01-31T23:00:00Z

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tracker IDCountAvgp50p90p95MinMaxThreshold↑Threshold↓
▶ PaymentService.processPayment1,234156ms
└─ AccountService.changeAmount
+
+ + + +``` + +--- + +### Annotation Enhancement + +#### [MODIFY] [TimerNinjaTracker.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaTracker.java) + +Add `trackerId` parameter for unique identification. + +```diff +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.CONSTRUCTOR, ElementType.METHOD}) +public @interface TimerNinjaTracker { + ChronoUnit timeUnit() default ChronoUnit.MILLIS; + boolean enabled() default true; + boolean includeArgs() default false; + int threshold() default -1; + ++ /** ++ * Unique identifier for this tracker in statistics. ++ * If empty, defaults to "ClassName.methodName". ++ * Useful when same method signature exists in different classes. ++ */ ++ String trackerId() default ""; +} +``` + +--- + +### Configuration Enhancement + +#### [MODIFY] [TimerNinjaConfiguration.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaConfiguration.java) + +```diff +public class TimerNinjaConfiguration { + private static TimerNinjaConfiguration instance; + private boolean enabledSystemOutLog; ++ private boolean statisticsReportingEnabled = false; ++ private int statisticsBufferSize = 1000; ++ private boolean useFullMethodSignature = false; // Shortened by default + + // ... existing methods ... + ++ /** Enable/disable statistics collection. */ ++ public synchronized void enableStatisticsReporting(boolean enabled); ++ public boolean isStatisticsReportingEnabled(); ++ ++ /** Set max samples per tracker (default: 1000). FIFO eviction when full. */ ++ public synchronized void setStatisticsBufferSize(int size); ++ public int getStatisticsBufferSize(); ++ ++ /** Use full method signature in reports (default: false = shortened). */ ++ public synchronized void setUseFullMethodSignature(boolean useFull); ++ public boolean isUseFullMethodSignature(); ++ ++ /** Get the StatisticsCollector instance. */ ++ public StatisticsCollector getStatisticsCollector(); ++ ++ /** Print statistics report to the configured logger. */ ++ public void printStatisticsReport(); ++ ++ /** Get report as string. @param format "text", "json", or "html" */ ++ public String getStatisticsReportAsString(String format); ++ ++ /** Get report as bytes (for REST download). @param format "text", "json", or "html" */ ++ public byte[] getStatisticsReportAsBytes(String format); ++ ++ /** Reset all collected statistics. */ ++ public void resetStatistics(); +} +``` + +--- + +### AspectJ Integration + +#### [MODIFY] [TimeTrackingAspect.aj](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/main/aspectj/io/github/thanglequoc/timerninja/TimeTrackingAspect.aj) + +Add statistics recording after timing captured. + +```diff +Object around(): methodAnnotatedWithTimerNinjaTracker() { + // ... existing code ... + ++ // Get tracker ID (custom or auto-generated) ++ String trackerId = TimerNinjaUtil.getTrackerId(methodSignature); ++ String className = methodSignature.getDeclaringType().getName(); + + // Method invocation + long startTime = System.currentTimeMillis(); + Object object = proceed(); + long endTime = System.currentTimeMillis(); ++ long executionTimeMs = endTime - startTime; + + if (isTrackerEnabled) { + ChronoUnit trackingTimeUnit = TimerNinjaUtil.getTrackingTimeUnit(methodSignature); +- long executionTime = TimerNinjaUtil.convertFromMillis(endTime - startTime, trackingTimeUnit); ++ long executionTime = TimerNinjaUtil.convertFromMillis(executionTimeMs, trackingTimeUnit); + trackerItemContext.setExecutionTime(executionTime); + trackerItemContext.setTimeUnit(trackingTimeUnit); + ++ // Record statistics if enabled ++ if (TimerNinjaConfiguration.getInstance().isStatisticsReportingEnabled()) { ++ String parentTrackerId = trackingCtx.getParentTrackerId(); ++ StatisticsCollector.getInstance().recordExecution( ++ trackerId, className, methodSignatureString, ++ executionTimeMs, threshold, parentTrackerId ++ ); ++ } + + trackingCtx.decreasePointerDepth(); + } + // ... rest unchanged ... +} +``` + +> [!NOTE] +> The same modification applies to the constructor advice. + +--- + +### Block-based Tracking Integration + +#### [MODIFY] [TimerNinjaBlock.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaBlock.java) + +Add statistics recording to block-based measurement. + +```diff +public static void measure(String blockName, BlockTrackerConfig config, Runnable codeBlock) { + // ... existing timing logic ... + + long startTime = System.currentTimeMillis(); + codeBlock.run(); + long endTime = System.currentTimeMillis(); ++ long executionTimeMs = endTime - startTime; + + // ... existing trace context handling ... + ++ // Record statistics if enabled ++ if (TimerNinjaConfiguration.getInstance().isStatisticsReportingEnabled()) { ++ String trackerId = (config != null && config.getTrackerId() != null) ++ ? config.getTrackerId() ++ : "Block:" + blockName; ++ int threshold = (config != null) ? config.getThreshold() : -1; ++ String parentTrackerId = (trackingCtx != null) ? trackingCtx.getParentTrackerId() : null; ++ ++ StatisticsCollector.getInstance().recordExecution( ++ trackerId, "TimerNinjaBlock", blockName, ++ executionTimeMs, threshold, parentTrackerId ++ ); ++ } +} +``` + +#### [MODIFY] [BlockTrackerConfig.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/main/aspectj/io/github/thanglequoc/timerninja/BlockTrackerConfig.java) + +Add `trackerId` configuration. + +```diff +public class BlockTrackerConfig { + private ChronoUnit timeUnit = ChronoUnit.MILLIS; + private int threshold = -1; ++ private String trackerId; + ++ public BlockTrackerConfig setTrackerId(String trackerId) { ++ this.trackerId = trackerId; ++ return this; ++ } ++ public String getTrackerId() { return trackerId; } +} +``` + +--- + +## API Usage Examples + +### Basic Usage + +```java +// Enable statistics tracking +TimerNinjaConfiguration config = TimerNinjaConfiguration.getInstance(); +config.enableStatisticsReporting(true); +config.setStatisticsBufferSize(500); // Optional + +// ... application runs, methods execute ... + +// Get report as string +String textReport = config.getStatisticsReportAsString("text"); +String jsonReport = config.getStatisticsReportAsString("json"); +String htmlReport = config.getStatisticsReportAsString("html"); + +// Get report as bytes (for REST download) +byte[] htmlBytes = config.getStatisticsReportAsBytes("html"); +``` + +### REST Endpoint Example (Spring Boot) + +```java +@RestController +@RequestMapping("/api/timer-ninja") +public class TimerNinjaStatsController { + + @PostMapping("/report") + public ResponseEntity downloadReport( + @RequestParam(defaultValue = "html") String format) { + + byte[] report = TimerNinjaConfiguration.getInstance() + .getStatisticsReportAsBytes(format); + + String contentType = switch (format) { + case "json" -> "application/json"; + case "html" -> "text/html"; + default -> "text/plain"; + }; + + String filename = "timer-ninja-report." + + (format.equals("text") ? "txt" : format); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header("Content-Disposition", "attachment; filename=" + filename) + .body(report); + } + + @PostMapping("/reset") + public ResponseEntity resetStatistics() { + TimerNinjaConfiguration.getInstance().resetStatistics(); + return ResponseEntity.ok().build(); + } +} +``` + +### Custom Tracker ID + +```java +// Avoid collision when same method signature exists in multiple classes +@TimerNinjaTracker(trackerId = "billing.processPayment") +public void processPayment(User user, int amount) { } + +// In another class with same method name +@TimerNinjaTracker(trackerId = "refund.processPayment") +public void processPayment(User user, int amount) { } +``` + +### Block-based Tracking with ID + +```java +BlockTrackerConfig config = new BlockTrackerConfig() + .setTrackerId("custom-batch-process") + .setThreshold(500); + +TimerNinjaBlock.measure("batch processing", config, () -> { + processBatch(items); +}); +``` + +--- + +## Performance Considerations + +| Operation | Overhead | Notes | +|-----------|----------|-------| +| Statistics enabled check | ~1ns | Simple boolean check | +| Recording execution | ~50-100ns | HashMap get + list add + counter increment | +| Avg/Percentile calculation | O(n log n) | Only on report extraction, not on record | +| Report generation | O(m × n log n) | m = methods, n = samples per method | +| Memory per tracker | ~8KB | 1000 samples × 8 bytes per Long | + +--- + +## Verification Plan + +### Automated Tests + +#### [NEW] [StatisticsCollectorTest.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/test/aspectj/io/github/thanglequoc/timerninja/StatisticsCollectorTest.java) + +| Test Case | Description | +|-----------|-------------| +| `testRecordWithCustomTrackerId` | Verify recording with user-defined tracker ID | +| `testAutoGeneratedTrackerId` | Verify `ClassName.methodName` default | +| `testThresholdExceedCount` | Verify threshold exceed/within counts | +| `testParentChildHierarchy` | Verify parent-child relationship tracking | +| `testBufferOverflowFIFO` | Verify FIFO eviction when buffer full | +| `testConcurrentAccess` | Verify thread-safety | + +#### [NEW] [StatisticsReportGeneratorTest.java](file:///Users/thanglequoc/projects/GitHub/timer-ninja/src/test/aspectj/io/github/thanglequoc/timerninja/StatisticsReportGeneratorTest.java) + +| Test Case | Description | +|-----------|-------------| +| `testTextReportFormat` | Verify text output format | +| `testJsonReportValid` | Verify JSON is valid and parseable | +| `testHtmlReportContainsBootstrap` | Verify HTML contains Bootstrap CDN | +| `testHtmlReportHierarchy` | Verify collapsible rows for child trackers | +| `testReportAsBytes` | Verify byte[] output for downloads | + +### Test Commands + +```bash +cd /Users/thanglequoc/projects/GitHub/timer-ninja +./gradlew test +``` + +--- + +## Files Summary + +| File | Action | Description | +|------|--------|-------------| +| `MethodStatistics.java` | NEW | Per-tracker stats with threshold tracking | +| `StatisticsCollector.java` | NEW | Singleton collector manager | +| `StatisticsReportGenerator.java` | NEW | Text/JSON/HTML report generation | +| `TimerNinjaTracker.java` | MODIFY | Add `trackerId` parameter | +| `TimerNinjaConfiguration.java` | MODIFY | Add statistics config methods | +| `TimeTrackingAspect.aj` | MODIFY | Add recording hook | +| `TimerNinjaBlock.java` | MODIFY | Add statistics recording | +| `BlockTrackerConfig.java` | MODIFY | Add `trackerId` config | +| `StatisticsCollectorTest.java` | NEW | Unit tests | +| `StatisticsReportGeneratorTest.java` | NEW | Report tests | +| `README.md` | MODIFY | Add documentation | + +--- + +## Implementation Order + +1. **Phase 1**: Core classes + - `MethodStatistics.java` with threshold tracking + - `StatisticsCollector.java` with tracker ID support + - `StatisticsReportGenerator.java` (text, JSON, HTML) + - Unit tests + +2. **Phase 2**: Integration + - Modify `TimerNinjaTracker.java` to add `trackerId` + - Modify `TimerNinjaConfiguration.java` + - Modify `TimeTrackingAspect.aj` + - Modify `TimerNinjaBlock.java` and `BlockTrackerConfig.java` + +3. **Phase 3**: Testing & Documentation + - Integration tests + - Update README.md diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/BlockTrackerConfig.java b/src/main/aspectj/io/github/thanglequoc/timerninja/BlockTrackerConfig.java index 2bd7288..cf20a0a 100644 --- a/src/main/aspectj/io/github/thanglequoc/timerninja/BlockTrackerConfig.java +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/BlockTrackerConfig.java @@ -5,18 +5,21 @@ /** * Configuration class for code block tracking in {@link TimerNinjaBlock}. *

- * This class provides a builder-style API to configure how a code block should be tracked, + * This class provides a builder-style API to configure how a code block should + * be tracked, * similar to the options available in {@link TimerNinjaTracker} annotation. *

*

* Example usage: + * *

  * BlockTrackerConfig config = new BlockTrackerConfig()
- *     .setTimeUnit(ChronoUnit.SECONDS)
- *     .setEnabled(true)
- *     .setThreshold(5);
+ *         .setTimeUnit(ChronoUnit.SECONDS)
+ *         .setEnabled(true)
+ *         .setThreshold(5)
+ *         .setTrackerId("my-custom-tracker");
  * 
- * TimerNinjaBlock.measure("my block", config, () -> {
+ * TimerNinjaBlock.measure("my block", config, () -> {
  *     // code to track
  * });
  * 
@@ -31,23 +34,32 @@ public class BlockTrackerConfig { /** * Determine if this tracker should be active. - * Set to false will disable this tracker from the overall tracking trace result. + * Set to false will disable this tracker from the overall tracking trace + * result. */ private boolean enabled = true; /** - * Set the threshold of the tracker. If the execution time of the code block is less than + * Set the threshold of the tracker. If the execution time of the code block is + * less than * the threshold, the tracker will not be included in the tracking result. * Default is 0, which means no threshold is set. */ private int threshold = 0; + /** + * Unique identifier for this block tracker in statistics. + * If null, defaults to "Block:blockName". + */ + private String trackerId = null; + /** * Default constructor creating a config with default values: *
    - *
  • timeUnit: MILLIS
  • - *
  • enabled: true
  • - *
  • threshold: 0 (no threshold)
  • + *
  • timeUnit: MILLIS
  • + *
  • enabled: true
  • + *
  • threshold: 0 (no threshold)
  • + *
  • trackerId: null (will use default)
  • *
*/ public BlockTrackerConfig() { @@ -80,7 +92,8 @@ public BlockTrackerConfig setEnabled(boolean enabled) { /** * Sets the threshold for execution time. - * Code blocks with execution time less than this threshold will not be included in the trace. + * Code blocks with execution time less than this threshold will not be included + * in the trace. * * @param threshold the threshold value in the configured time unit. * Set to 0 or negative to disable threshold filtering. @@ -91,6 +104,18 @@ public BlockTrackerConfig setThreshold(int threshold) { return this; } + /** + * Sets a custom tracker ID for statistics. + * If not set, defaults to "Block:blockName". + * + * @param trackerId the custom tracker ID + * @return this config instance for method chaining + */ + public BlockTrackerConfig setTrackerId(String trackerId) { + this.trackerId = trackerId; + return this; + } + /** * Gets the configured time unit. * @@ -118,6 +143,15 @@ public int getThreshold() { return threshold; } + /** + * Gets the custom tracker ID. + * + * @return the tracker ID, or null if not set + */ + public String getTrackerId() { + return trackerId; + } + /** * Creates a copy of this configuration. * @@ -128,6 +162,7 @@ public BlockTrackerConfig copy() { copy.timeUnit = this.timeUnit; copy.enabled = this.enabled; copy.threshold = this.threshold; + copy.trackerId = this.trackerId; return copy; } @@ -137,6 +172,7 @@ public String toString() { "timeUnit=" + timeUnit + ", enabled=" + enabled + ", threshold=" + threshold + + ", trackerId='" + trackerId + '\'' + '}'; } } \ No newline at end of file diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/MethodStatistics.java b/src/main/aspectj/io/github/thanglequoc/timerninja/MethodStatistics.java new file mode 100644 index 0000000..a795c28 --- /dev/null +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/MethodStatistics.java @@ -0,0 +1,232 @@ +package io.github.thanglequoc.timerninja; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Statistics holder for a single tracker (identified by unique trackerId). + *

+ * Stores execution time samples in a circular buffer with FIFO eviction. + * All statistical calculations (average, percentiles) are performed on-demand + * to minimize performance overhead during recording. + */ +public class MethodStatistics { + + private final String trackerId; + private final String methodSignature; + private final String className; + private final int maxBufferSize; + + // Circular buffer for execution times (in milliseconds) + private final List executionTimes; + + // Threshold tracking + private int thresholdMs = -1; + private int thresholdExceededCount = 0; + private int thresholdWithinCount = 0; + + // Parent-child hierarchy + private String parentTrackerId; + private final List childTrackerIds; + + /** + * Creates a new MethodStatistics instance. + * + * @param trackerId unique identifier for this tracker + * @param className fully qualified class name + * @param methodSignature method signature (shortened or full) + * @param maxBufferSize maximum number of samples to keep + */ + public MethodStatistics(String trackerId, String className, String methodSignature, int maxBufferSize) { + this.trackerId = trackerId; + this.className = className; + this.methodSignature = methodSignature; + this.maxBufferSize = maxBufferSize; + this.executionTimes = new ArrayList<>(); + this.childTrackerIds = new ArrayList<>(); + } + + /** + * Records an execution time sample. + * If the buffer is full, the oldest entry is removed (FIFO eviction). + * + * @param executionTimeMs execution time in milliseconds + * @param thresholdMs threshold value (-1 if not set) + */ + public synchronized void recordExecution(long executionTimeMs, int thresholdMs) { + // FIFO eviction if buffer is full + if (executionTimes.size() >= maxBufferSize) { + executionTimes.remove(0); + } + executionTimes.add(executionTimeMs); + + // Update threshold tracking + if (thresholdMs > 0) { + this.thresholdMs = thresholdMs; + if (executionTimeMs > thresholdMs) { + thresholdExceededCount++; + } else { + thresholdWithinCount++; + } + } + } + + /** + * Returns the total number of recorded invocations. + * Note: This may be greater than the buffer size if eviction has occurred. + * + * @return total invocation count + */ + public int getInvocationCount() { + return thresholdExceededCount + thresholdWithinCount + + (thresholdMs <= 0 ? executionTimes.size() : 0); + } + + /** + * Returns the number of samples currently in the buffer. + * + * @return sample count + */ + public synchronized int getSampleCount() { + return executionTimes.size(); + } + + /** + * Calculates the average execution time on-demand. + * + * @return average execution time in milliseconds, or 0 if no samples + */ + public synchronized long calculateAverage() { + if (executionTimes.isEmpty()) { + return 0; + } + long sum = 0; + for (Long time : executionTimes) { + sum += time; + } + return sum / executionTimes.size(); + } + + /** + * Calculates a percentile value on-demand. + * + * @param percentile the percentile to calculate (e.g., 50, 90, 95) + * @return the percentile value in milliseconds, or 0 if no samples + */ + public synchronized long calculatePercentile(int percentile) { + if (executionTimes.isEmpty()) { + return 0; + } + if (percentile < 0 || percentile > 100) { + throw new IllegalArgumentException("Percentile must be between 0 and 100"); + } + + List sorted = new ArrayList<>(executionTimes); + Collections.sort(sorted); + + int index = (int) Math.ceil((percentile / 100.0) * sorted.size()) - 1; + index = Math.max(0, Math.min(index, sorted.size() - 1)); + return sorted.get(index); + } + + /** + * Returns the minimum execution time. + * + * @return minimum time in milliseconds, or 0 if no samples + */ + public synchronized long getMin() { + if (executionTimes.isEmpty()) { + return 0; + } + long min = Long.MAX_VALUE; + for (Long time : executionTimes) { + if (time < min) { + min = time; + } + } + return min; + } + + /** + * Returns the maximum execution time. + * + * @return maximum time in milliseconds, or 0 if no samples + */ + public synchronized long getMax() { + if (executionTimes.isEmpty()) { + return 0; + } + long max = Long.MIN_VALUE; + for (Long time : executionTimes) { + if (time > max) { + max = time; + } + } + return max; + } + + /** + * Resets all statistics for this tracker. + */ + public synchronized void reset() { + executionTimes.clear(); + thresholdExceededCount = 0; + thresholdWithinCount = 0; + childTrackerIds.clear(); + } + + // --- Getters --- + + public String getTrackerId() { + return trackerId; + } + + public String getMethodSignature() { + return methodSignature; + } + + public String getClassName() { + return className; + } + + public int getThresholdMs() { + return thresholdMs; + } + + public int getThresholdExceededCount() { + return thresholdExceededCount; + } + + public int getThresholdWithinCount() { + return thresholdWithinCount; + } + + public String getParentTrackerId() { + return parentTrackerId; + } + + public void setParentTrackerId(String parentTrackerId) { + this.parentTrackerId = parentTrackerId; + } + + public List getChildTrackerIds() { + return new ArrayList<>(childTrackerIds); + } + + public synchronized void addChildTrackerId(String childTrackerId) { + if (!childTrackerIds.contains(childTrackerId)) { + childTrackerIds.add(childTrackerId); + } + } + + /** + * Returns a copy of the execution times buffer. + * Useful for testing and debugging. + * + * @return copy of execution times list + */ + public synchronized List getExecutionTimesCopy() { + return new ArrayList<>(executionTimes); + } +} diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/StatisticsCollector.java b/src/main/aspectj/io/github/thanglequoc/timerninja/StatisticsCollector.java new file mode 100644 index 0000000..ad9f6e4 --- /dev/null +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/StatisticsCollector.java @@ -0,0 +1,150 @@ +package io.github.thanglequoc.timerninja; + +import java.util.Collection; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Singleton class managing statistics collection for all tracked methods. + *

+ * This collector aggregates execution time data for methods and code blocks + * that are annotated with {@link TimerNinjaTracker} or measured using + * {@link TimerNinjaBlock}. + *

+ * Thread-safe implementation using {@link ConcurrentHashMap}. + */ +public class StatisticsCollector { + + private static volatile StatisticsCollector instance; + + // Tracker ID -> Statistics + private final ConcurrentHashMap statisticsMap; + private int maxBufferSizePerMethod = 1000; + + private StatisticsCollector() { + this.statisticsMap = new ConcurrentHashMap<>(); + } + + /** + * Returns the singleton instance of StatisticsCollector. + * + * @return the singleton instance + */ + public static StatisticsCollector getInstance() { + if (instance == null) { + synchronized (StatisticsCollector.class) { + if (instance == null) { + instance = new StatisticsCollector(); + } + } + } + return instance; + } + + /** + * Records an execution time for a tracked method or block. + *

+ * If this is the first recording for the given trackerId, a new + * {@link MethodStatistics} instance will be created. + * + * @param trackerId unique identifier for this tracker + * @param className fully qualified class name + * @param methodSignature method signature + * @param executionTimeMs execution time in milliseconds + * @param thresholdMs threshold value (-1 if not set) + * @param parentTrackerId parent tracker ID for hierarchy (null if root) + */ + public void recordExecution(String trackerId, String className, String methodSignature, + long executionTimeMs, int thresholdMs, String parentTrackerId) { + + MethodStatistics stats = statisticsMap.computeIfAbsent(trackerId, + id -> new MethodStatistics(id, className, methodSignature, maxBufferSizePerMethod)); + + stats.recordExecution(executionTimeMs, thresholdMs); + + // Set parent relationship + if (parentTrackerId != null && !parentTrackerId.isEmpty()) { + stats.setParentTrackerId(parentTrackerId); + + // Add this as a child to the parent + MethodStatistics parentStats = statisticsMap.get(parentTrackerId); + if (parentStats != null) { + parentStats.addChildTrackerId(trackerId); + } + } + } + + /** + * Gets statistics for a specific tracker. + * + * @param trackerId the tracker ID + * @return the statistics, or null if not found + */ + public MethodStatistics getStatistics(String trackerId) { + return statisticsMap.get(trackerId); + } + + /** + * Gets all statistics. + * + * @return collection of all MethodStatistics + */ + public Collection getAllStatistics() { + return statisticsMap.values(); + } + + /** + * Gets the statistics map. + * + * @return the statistics map + */ + public ConcurrentHashMap getStatisticsMap() { + return statisticsMap; + } + + /** + * Resets all collected statistics. + */ + public void reset() { + statisticsMap.clear(); + } + + /** + * Sets the maximum buffer size per method. + * + * @param size maximum number of samples to keep per method + */ + public void setMaxBufferSize(int size) { + if (size < 1) { + throw new IllegalArgumentException("Buffer size must be at least 1"); + } + this.maxBufferSizePerMethod = size; + } + + /** + * Gets the maximum buffer size per method. + * + * @return the maximum buffer size + */ + public int getMaxBufferSize() { + return maxBufferSizePerMethod; + } + + /** + * Returns the count of tracked methods. + * + * @return number of unique tracker IDs + */ + public int getTrackedMethodCount() { + return statisticsMap.size(); + } + + /** + * Checks if a tracker ID exists. + * + * @param trackerId the tracker ID to check + * @return true if the tracker exists + */ + public boolean hasTracker(String trackerId) { + return statisticsMap.containsKey(trackerId); + } +} diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/StatisticsReportGenerator.java b/src/main/aspectj/io/github/thanglequoc/timerninja/StatisticsReportGenerator.java new file mode 100644 index 0000000..f548103 --- /dev/null +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/StatisticsReportGenerator.java @@ -0,0 +1,407 @@ +package io.github.thanglequoc.timerninja; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Utility class for generating formatted statistics reports. + *

+ * Supports three output formats: + *

    + *
  • text - Plain text table format
  • + *
  • json - JSON format for programmatic consumption
  • + *
  • html - Self-contained HTML with Bootstrap 5 styling
  • + *
+ */ +public class StatisticsReportGenerator { + + private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC); + + private StatisticsReportGenerator() { + // Utility class + } + + /** + * Generates a report in the specified format. + * + * @param stats collection of method statistics + * @param format output format: "text", "json", or "html" + * @return formatted report as string + */ + public static String generateReport(Collection stats, String format) { + return switch (format.toLowerCase()) { + case "json" -> generateJsonReport(stats); + case "html" -> generateHtmlReport(stats); + default -> generateTextReport(stats); + }; + } + + /** + * Generates a report as bytes for download. + * + * @param stats collection of method statistics + * @param format output format: "text", "json", or "html" + * @return report as byte array + */ + public static byte[] generateReportAsBytes(Collection stats, String format) { + return generateReport(stats, format).getBytes(StandardCharsets.UTF_8); + } + + /** + * Generates a plain text report. + */ + public static String generateTextReport(Collection stats) { + StringBuilder sb = new StringBuilder(); + String timestamp = TIMESTAMP_FORMATTER.format(Instant.now()); + + sb.append("===== Timer Ninja Statistics Report =====\n"); + sb.append("Generated: ").append(timestamp).append("\n"); + sb.append("Total tracked methods: ").append(stats.size()).append("\n\n"); + + if (stats.isEmpty()) { + sb.append("No statistics recorded.\n"); + } else { + // Header + sb.append(String.format("%-50s | %7s | %8s | %8s | %8s | %8s | %8s | %8s | %10s | %10s%n", + "Tracker ID", "Count", "Avg", "p50", "p90", "p95", "Min", "Max", "Threshold↑", "Threshold↓")); + sb.append("-".repeat(140)).append("\n"); + + // Sort by tracker ID and handle hierarchy + List sorted = sortForDisplay(stats); + + for (MethodStatistics stat : sorted) { + String prefix = stat.getParentTrackerId() != null ? " └─ " : ""; + String trackerId = truncate(prefix + stat.getTrackerId(), 50); + + String thresholdUp = stat.getThresholdMs() > 0 + ? String.valueOf(stat.getThresholdExceededCount()) + : "-"; + String thresholdDown = stat.getThresholdMs() > 0 + ? String.valueOf(stat.getThresholdWithinCount()) + : "-"; + + sb.append(String.format("%-50s | %7d | %6dms | %6dms | %6dms | %6dms | %6dms | %6dms | %10s | %10s%n", + trackerId, + stat.getSampleCount(), + stat.calculateAverage(), + stat.calculatePercentile(50), + stat.calculatePercentile(90), + stat.calculatePercentile(95), + stat.getMin(), + stat.getMax(), + thresholdUp, + thresholdDown)); + } + } + + sb.append("\n===== End of Report =====\n"); + return sb.toString(); + } + + /** + * Generates a JSON report. + */ + public static String generateJsonReport(Collection stats) { + StringBuilder sb = new StringBuilder(); + String timestamp = TIMESTAMP_FORMATTER.format(Instant.now()); + + sb.append("{\n"); + sb.append(" \"generatedAt\": \"").append(timestamp).append("\",\n"); + sb.append(" \"totalMethods\": ").append(stats.size()).append(",\n"); + sb.append(" \"trackers\": [\n"); + + List statsList = new ArrayList<>(stats); + for (int i = 0; i < statsList.size(); i++) { + MethodStatistics stat = statsList.get(i); + sb.append(" {\n"); + sb.append(" \"trackerId\": \"").append(escapeJson(stat.getTrackerId())).append("\",\n"); + sb.append(" \"className\": \"").append(escapeJson(stat.getClassName())).append("\",\n"); + sb.append(" \"methodSignature\": \"").append(escapeJson(stat.getMethodSignature())).append("\",\n"); + sb.append(" \"count\": ").append(stat.getSampleCount()).append(",\n"); + sb.append(" \"avgMs\": ").append(stat.calculateAverage()).append(",\n"); + sb.append(" \"p50Ms\": ").append(stat.calculatePercentile(50)).append(",\n"); + sb.append(" \"p90Ms\": ").append(stat.calculatePercentile(90)).append(",\n"); + sb.append(" \"p95Ms\": ").append(stat.calculatePercentile(95)).append(",\n"); + sb.append(" \"minMs\": ").append(stat.getMin()).append(",\n"); + sb.append(" \"maxMs\": ").append(stat.getMax()).append(",\n"); + sb.append(" \"thresholdMs\": ").append(stat.getThresholdMs()).append(",\n"); + sb.append(" \"thresholdExceeded\": ").append(stat.getThresholdExceededCount()).append(",\n"); + sb.append(" \"thresholdWithin\": ").append(stat.getThresholdWithinCount()).append(",\n"); + sb.append(" \"parentTrackerId\": ").append(stat.getParentTrackerId() != null + ? "\"" + escapeJson(stat.getParentTrackerId()) + "\"" + : "null").append(",\n"); + sb.append(" \"childTrackerIds\": ["); + List children = stat.getChildTrackerIds(); + for (int j = 0; j < children.size(); j++) { + sb.append("\"").append(escapeJson(children.get(j))).append("\""); + if (j < children.size() - 1) + sb.append(", "); + } + sb.append("]\n"); + sb.append(" }"); + if (i < statsList.size() - 1) + sb.append(","); + sb.append("\n"); + } + + sb.append(" ]\n"); + sb.append("}\n"); + return sb.toString(); + } + + /** + * Generates a self-contained HTML report with Bootstrap 5 styling. + */ + public static String generateHtmlReport(Collection stats) { + StringBuilder sb = new StringBuilder(); + String timestamp = TIMESTAMP_FORMATTER.format(Instant.now()); + + // HTML Header with Bootstrap CDN + sb.append(""" + + + + + + Timer Ninja Statistics Report + + + + +
+
+ 🥷 +
+

Timer Ninja Statistics Report

+

Generated: %s

+
+
+ +
+
+
Summary
+

Total tracked methods: %d

+
+
+ """.formatted(timestamp, stats.size())); + + if (stats.isEmpty()) { + sb.append(""" +
+ No statistics recorded yet. Enable statistics reporting and execute some tracked methods. +
+ """); + } else { + sb.append(""" +
+ + + + + + + + + + + + + + + + """); + + // Group by parent for hierarchy display + List roots = stats.stream() + .filter(s -> s.getParentTrackerId() == null) + .sorted(Comparator.comparing(MethodStatistics::getTrackerId)) + .collect(Collectors.toList()); + + Map> byParent = stats.stream() + .filter(s -> s.getParentTrackerId() != null) + .collect(Collectors.groupingBy(MethodStatistics::getParentTrackerId)); + + int rowIndex = 0; + for (MethodStatistics root : roots) { + rowIndex++; + List children = byParent.getOrDefault(root.getTrackerId(), List.of()); + boolean hasChildren = !children.isEmpty(); + + appendHtmlRow(sb, root, rowIndex, hasChildren, false); + + if (hasChildren) { + for (MethodStatistics child : children) { + appendHtmlChildRow(sb, child, rowIndex); + } + } + } + + // Also add orphan children (parent not in stats) + for (MethodStatistics stat : stats) { + if (stat.getParentTrackerId() != null && !roots.stream() + .anyMatch(r -> r.getTrackerId().equals(stat.getParentTrackerId()))) { + rowIndex++; + appendHtmlRow(sb, stat, rowIndex, false, true); + } + } + + sb.append(""" + +
Tracker IDCountAvgp50p90p95MinMaxThreshold
+
+ """); + } + + // HTML Footer + sb.append(""" +
+ + + + + """); + + return sb.toString(); + } + + private static void appendHtmlRow(StringBuilder sb, MethodStatistics stat, int rowIndex, + boolean hasChildren, boolean isOrphan) { + String expandBtn = hasChildren + ? " " + : ""; + String rowClass = hasChildren ? "expand-btn" : ""; + String dataTarget = hasChildren ? "data-target='.child-row-" + rowIndex + "'" : ""; + String prefix = isOrphan ? "└─ " : ""; + + sb.append(String.format(""" + + %s%s%s + %,d + %dms + %dms + %dms + %dms + %dms + %dms + %s + + """, + rowClass, dataTarget, + expandBtn, prefix, escapeHtml(stat.getTrackerId()), + stat.getSampleCount(), + stat.calculateAverage(), + stat.calculatePercentile(50), + stat.calculatePercentile(90), + stat.calculatePercentile(95), + stat.getMin(), + stat.getMax(), + formatThresholdBadge(stat))); + } + + private static void appendHtmlChildRow(StringBuilder sb, MethodStatistics stat, int parentIndex) { + sb.append(String.format(""" + + └─ %s + %,d + %dms + %dms + %dms + %dms + %dms + %dms + %s + + """, + parentIndex, + escapeHtml(stat.getTrackerId()), + stat.getSampleCount(), + stat.calculateAverage(), + stat.calculatePercentile(50), + stat.calculatePercentile(90), + stat.calculatePercentile(95), + stat.getMin(), + stat.getMax(), + formatThresholdBadge(stat))); + } + + private static String formatThresholdBadge(MethodStatistics stat) { + if (stat.getThresholdMs() <= 0) { + return "-"; + } + return String.format( + "↑%d" + + "↓%d", + stat.getThresholdExceededCount(), + stat.getThresholdWithinCount()); + } + + private static List sortForDisplay(Collection stats) { + // Sort roots first, then children + return stats.stream() + .sorted((a, b) -> { + if (a.getParentTrackerId() == null && b.getParentTrackerId() != null) + return -1; + if (a.getParentTrackerId() != null && b.getParentTrackerId() == null) + return 1; + return a.getTrackerId().compareTo(b.getTrackerId()); + }) + .collect(Collectors.toList()); + } + + private static String truncate(String str, int maxLen) { + if (str.length() <= maxLen) + return str; + return str.substring(0, maxLen - 3) + "..."; + } + + private static String escapeJson(String str) { + if (str == null) + return ""; + return str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private static String escapeHtml(String str) { + if (str == null) + return ""; + return str.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/TimeTrackingAspect.aj b/src/main/aspectj/io/github/thanglequoc/timerninja/TimeTrackingAspect.aj index 780369b..09262da 100644 --- a/src/main/aspectj/io/github/thanglequoc/timerninja/TimeTrackingAspect.aj +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/TimeTrackingAspect.aj @@ -62,25 +62,45 @@ public aspect TimeTrackingAspect { // Method invocation long startTime = System.currentTimeMillis(); - Object object = proceed(); - long endTime = System.currentTimeMillis(); - - if (isTrackerEnabled) { - LOGGER.debug("{} ({})|{}| TrackerItemContext {} finished tracking on: {} - {}. Evaluating execution time...", - threadName, threadId, traceContextId, uuid, methodSignatureString, methodArgumentString); - ChronoUnit trackingTimeUnit = TimerNinjaUtil.getTrackingTimeUnit(methodSignature); - long executionTime = TimerNinjaUtil.convertFromMillis(endTime - startTime, trackingTimeUnit); - trackerItemContext.setExecutionTime(executionTime); - trackerItemContext.setTimeUnit(trackingTimeUnit); - LOGGER.debug("{} ({})|{}| TrackerItemContext: {}", threadName, threadId, traceContextId, trackerItemContext); - trackingCtx.decreasePointerDepth(); - } - - if (trackingCtx.getPointerDepth() == 0) { - TimerNinjaUtil.logTimerContextTrace(trackingCtx); - localTrackingCtx.remove(); - LOGGER.debug("{} ({})| TimerNinjaTracking context {} is completed and has been removed", - threadName, threadId, traceContextId); + Object object = null; + try { + object = proceed(); + } finally { + long endTime = System.currentTimeMillis(); + long executionTimeMs = endTime - startTime; + + if (isTrackerEnabled) { + LOGGER.debug("{} ({})|{}| TrackerItemContext {} finished tracking on: {} - {}. Evaluating execution time...", + threadName, threadId, traceContextId, uuid, methodSignatureString, methodArgumentString); + ChronoUnit trackingTimeUnit = TimerNinjaUtil.getTrackingTimeUnit(methodSignature); + long executionTime = TimerNinjaUtil.convertFromMillis(executionTimeMs, trackingTimeUnit); + trackerItemContext.setExecutionTime(executionTime); + trackerItemContext.setTimeUnit(trackingTimeUnit); + LOGGER.debug("{} ({})|{}| TrackerItemContext: {}", threadName, threadId, traceContextId, trackerItemContext); + + // Record statistics if enabled + if (TimerNinjaConfiguration.getInstance().isStatisticsReportingEnabled()) { + String trackerId = TimerNinjaUtil.getTrackerId(methodSignature); + String className = methodSignature.getDeclaringType().getName(); + String shortenedSignature = TimerNinjaUtil.getShortenedMethodSignature(methodSignature); + String parentTrackerId = trackingCtx.getPointerDepth() > 1 ? trackingCtx.getCurrentParentTrackerId() : null; + + StatisticsCollector.getInstance().recordExecution( + trackerId, className, shortenedSignature, + executionTimeMs, threshold, parentTrackerId + ); + trackingCtx.setCurrentParentTrackerId(trackerId); + } + + trackingCtx.decreasePointerDepth(); + } + + if (trackingCtx.getPointerDepth() == 0) { + TimerNinjaUtil.logTimerContextTrace(trackingCtx); + localTrackingCtx.remove(); + LOGGER.debug("{} ({})| TimerNinjaTracking context {} is completed and has been removed", + threadName, threadId, traceContextId); + } } return object; @@ -122,28 +142,47 @@ public aspect TimeTrackingAspect { trackingCtx.increasePointerDepth(); } - // Method invocation + // Constructor invocation long startTime = System.currentTimeMillis(); - Object object = proceed(); - long endTime = System.currentTimeMillis(); - - if (isTrackerEnabled) { - LOGGER.debug("{} ({})|{}| TrackerItemContext {} finished tracking on constructor: {} - {}. Evaluating execution time...", - threadName, threadId, traceContextId, uuid, constructorSignatureString, constructorArgumentString); - ChronoUnit trackingTimeUnit = TimerNinjaUtil.getTrackingTimeUnit(constructorSignature); - trackerItemContext.setExecutionTime(TimerNinjaUtil.convertFromMillis(endTime - startTime, trackingTimeUnit)); - trackerItemContext.setTimeUnit(trackingTimeUnit); - LOGGER.debug("{} ({})|{}| TrackerItemContext: {}", threadName, threadId, traceContextId, trackerItemContext); - trackingCtx.decreasePointerDepth(); + Object object = null; + try { + object = proceed(); + } finally { + long endTime = System.currentTimeMillis(); + long executionTimeMs = endTime - startTime; + + if (isTrackerEnabled) { + LOGGER.debug("{} ({})|{}| TrackerItemContext {} finished tracking on constructor: {} - {}. Evaluating execution time...", + threadName, threadId, traceContextId, uuid, constructorSignatureString, constructorArgumentString); + ChronoUnit trackingTimeUnit = TimerNinjaUtil.getTrackingTimeUnit(constructorSignature); + trackerItemContext.setExecutionTime(TimerNinjaUtil.convertFromMillis(executionTimeMs, trackingTimeUnit)); + trackerItemContext.setTimeUnit(trackingTimeUnit); + LOGGER.debug("{} ({})|{}| TrackerItemContext: {}", threadName, threadId, traceContextId, trackerItemContext); + + // Record statistics if enabled + if (TimerNinjaConfiguration.getInstance().isStatisticsReportingEnabled()) { + String trackerId = TimerNinjaUtil.getTrackerId(constructorSignature); + String className = constructorSignature.getDeclaringType().getName(); + String shortenedSignature = TimerNinjaUtil.getShortenedConstructorSignature(constructorSignature); + String parentTrackerId = trackingCtx.getPointerDepth() > 1 ? trackingCtx.getCurrentParentTrackerId() : null; + + StatisticsCollector.getInstance().recordExecution( + trackerId, className, shortenedSignature, + executionTimeMs, threshold, parentTrackerId + ); + trackingCtx.setCurrentParentTrackerId(trackerId); + } + + trackingCtx.decreasePointerDepth(); + } + + if (trackingCtx.getPointerDepth() == 0) { + TimerNinjaUtil.logTimerContextTrace(trackingCtx); + localTrackingCtx.remove(); + LOGGER.debug("{} ({})| TimerNinjaTracking context {} is completed and has been removed", + threadName, threadId, traceContextId); + } } - - if (trackingCtx.getPointerDepth() == 0) { - TimerNinjaUtil.logTimerContextTrace(trackingCtx); - localTrackingCtx.remove(); - LOGGER.debug("{} ({})| TimerNinjaTracking context {} is completed and has been removed", - threadName, threadId, traceContextId); - } - return object; } diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaBlock.java b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaBlock.java index 1726614..f25ded5 100644 --- a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaBlock.java +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaBlock.java @@ -9,18 +9,24 @@ /** * Utility class for measuring execution time of arbitrary code blocks. *

- * This class provides static methods to wrap any code block with time tracking functionality, - * similar to how {@link TimerNinjaTracker} annotation works for methods and constructors. - * Code blocks tracked with this class will be integrated into the existing Timer Ninja + * This class provides static methods to wrap any code block with time tracking + * functionality, + * similar to how {@link TimerNinjaTracker} annotation works for methods and + * constructors. + * Code blocks tracked with this class will be integrated into the existing + * Timer Ninja * tracking context if called from within an already-tracked method. *

*

- * The tracking context is managed by {@link TimerNinjaContextManager}, ensuring that - * code blocks tracked with this class share the same trace context as methods and constructors + * The tracking context is managed by {@link TimerNinjaContextManager}, ensuring + * that + * code blocks tracked with this class share the same trace context as methods + * and constructors * tracked with {@link TimerNinjaTracker}. *

*

* Example usage: + * *

  * TimerNinjaBlock.measure(() -> {
  *     // Your code here
@@ -29,6 +35,7 @@
  * 
*

* Example with custom name: + * *

  * TimerNinjaBlock.measure("data processing", () -> {
  *     processData(data);
@@ -36,10 +43,11 @@
  * 
*

* Example with configuration: + * *

  * BlockTrackerConfig config = new BlockTrackerConfig()
- *     .setTimeUnit(ChronoUnit.SECONDS)
- *     .setThreshold(5);
+ *         .setTimeUnit(ChronoUnit.SECONDS)
+ *         .setThreshold(5);
  * TimerNinjaBlock.measure("expensive operation", config, () -> {
  *     expensiveOperation();
  * });
@@ -71,17 +79,18 @@ public static void measure(String blockName, Runnable codeBlock) {
      * Measures the execution time of a code block with custom configuration.
      *
      * @param blockName the name to identify this code block in the trace
-     * @param config the configuration for this code block (can be null for defaults)
+     * @param config    the configuration for this code block (can be null for
+     *                  defaults)
      * @param codeBlock the code block to measure
      */
     public static void measure(String blockName, BlockTrackerConfig config, Runnable codeBlock) {
         if (codeBlock == null) {
             throw new IllegalArgumentException("Code block cannot be null");
         }
-        
+
         // Use default configuration if none provided
         BlockTrackerConfig actualConfig = config != null ? config : new BlockTrackerConfig();
-        
+
         // Initialize tracking context if needed
         if (TimerNinjaContextManager.isTrackingContextNull()) {
             TimerNinjaContextManager.setLocalTrackingCtx(TimerNinjaContextManager.initTrackingContext());
@@ -93,10 +102,9 @@ public static void measure(String blockName, BlockTrackerConfig config, Runnable
         boolean isTrackerEnabled = actualConfig.isEnabled();
 
         TrackerItemContext trackerItemContext = new TrackerItemContext(
-            trackingCtx.getPointerDepth(), 
-            blockName
-        );
-        
+                trackingCtx.getPointerDepth(),
+                blockName);
+
         // Set threshold if configured
         if (actualConfig.getThreshold() > 0) {
             trackerItemContext.setThreshold(actualConfig.getThreshold());
@@ -109,7 +117,7 @@ public static void measure(String blockName, BlockTrackerConfig config, Runnable
 
         if (isTrackerEnabled) {
             LOGGER.debug("{} ({})|{}| TrackerItemContext {} initiated, start tracking on code block: {}",
-                threadName, threadId, traceContextId, uuid, blockName);
+                    threadName, threadId, traceContextId, uuid, blockName);
             trackingCtx.addItemContext(uuid, trackerItemContext);
             trackingCtx.increasePointerDepth();
         }
@@ -120,19 +128,38 @@ public static void measure(String blockName, BlockTrackerConfig config, Runnable
             codeBlock.run();
         } catch (Exception e) {
             LOGGER.warn("{} ({})|{}| Exception occurred in code block {}: {}",
-                threadName, threadId, traceContextId, blockName, e.getMessage());
+                    threadName, threadId, traceContextId, blockName, e.getMessage());
             throw e;
         } finally {
             long endTime = System.currentTimeMillis();
+            long executionTimeMs = endTime - startTime;
 
             if (isTrackerEnabled) {
-                LOGGER.debug("{} ({})|{}| TrackerItemContext {} finished tracking on code block: {}. Evaluating execution time...",
-                    threadName, threadId, traceContextId, uuid, blockName);
+                LOGGER.debug(
+                        "{} ({})|{}| TrackerItemContext {} finished tracking on code block: {}. Evaluating execution time...",
+                        threadName, threadId, traceContextId, uuid, blockName);
                 ChronoUnit trackingTimeUnit = actualConfig.getTimeUnit();
-                long executionTime = TimerNinjaUtil.convertFromMillis(endTime - startTime, trackingTimeUnit);
+                long executionTime = TimerNinjaUtil.convertFromMillis(executionTimeMs, trackingTimeUnit);
                 trackerItemContext.setExecutionTime(executionTime);
                 trackerItemContext.setTimeUnit(trackingTimeUnit);
-                LOGGER.debug("{} ({})|{}| TrackerItemContext: {}", threadName, threadId, traceContextId, trackerItemContext);
+                LOGGER.debug("{} ({})|{}| TrackerItemContext: {}", threadName, threadId, traceContextId,
+                        trackerItemContext);
+
+                // Record statistics if enabled
+                if (TimerNinjaConfiguration.getInstance().isStatisticsReportingEnabled()) {
+                    String trackerId = (actualConfig.getTrackerId() != null && !actualConfig.getTrackerId().isEmpty())
+                            ? actualConfig.getTrackerId()
+                            : "Block:" + blockName;
+                    int threshold = actualConfig.getThreshold();
+                    String parentTrackerId = trackingCtx.getPointerDepth() > 1 ? trackingCtx.getCurrentParentTrackerId()
+                            : null;
+
+                    StatisticsCollector.getInstance().recordExecution(
+                            trackerId, "TimerNinjaBlock", blockName,
+                            executionTimeMs, threshold, parentTrackerId);
+                    trackingCtx.setCurrentParentTrackerId(trackerId);
+                }
+
                 trackingCtx.decreasePointerDepth();
             }
 
@@ -141,7 +168,7 @@ public static void measure(String blockName, BlockTrackerConfig config, Runnable
                 TimerNinjaUtil.logTimerContextTrace(trackingCtx);
                 localTrackingCtx.remove();
                 LOGGER.debug("{} ({})| TimerNinjaTracking context {} is completed and has been removed",
-                    threadName, threadId, traceContextId);
+                        threadName, threadId, traceContextId);
             }
         }
     }
@@ -150,7 +177,7 @@ public static void measure(String blockName, BlockTrackerConfig config, Runnable
      * Measures the execution time of a code block that returns a value.
      *
      * @param codeBlock the code block to measure
-     * @param  the return type of the code block
+     * @param        the return type of the code block
      * @return the result of the code block execution
      */
     public static  T measure(Supplier codeBlock) {
@@ -158,11 +185,12 @@ public static  T measure(Supplier codeBlock) {
     }
 
     /**
-     * Measures the execution time of a code block that returns a value with a custom name.
+     * Measures the execution time of a code block that returns a value with a
+     * custom name.
      *
      * @param blockName the name to identify this code block in the trace
      * @param codeBlock the code block to measure
-     * @param  the return type of the code block
+     * @param        the return type of the code block
      * @return the result of the code block execution
      */
     public static  T measure(String blockName, Supplier codeBlock) {
@@ -170,22 +198,24 @@ public static  T measure(String blockName, Supplier codeBlock) {
     }
 
     /**
-     * Measures the execution time of a code block that returns a value with custom configuration.
+     * Measures the execution time of a code block that returns a value with custom
+     * configuration.
      *
      * @param blockName the name to identify this code block in the trace
-     * @param config the configuration for this code block (can be null for defaults)
+     * @param config    the configuration for this code block (can be null for
+     *                  defaults)
      * @param codeBlock the code block to measure
-     * @param  the return type of the code block
+     * @param        the return type of the code block
      * @return the result of the code block execution
      */
     public static  T measure(String blockName, BlockTrackerConfig config, Supplier codeBlock) {
         if (codeBlock == null) {
             throw new IllegalArgumentException("Code block cannot be null");
         }
-        
+
         // Use default configuration if none provided
         BlockTrackerConfig actualConfig = config != null ? config : new BlockTrackerConfig();
-        
+
         // Initialize tracking context if needed
         if (TimerNinjaContextManager.isTrackingContextNull()) {
             TimerNinjaContextManager.setLocalTrackingCtx(TimerNinjaContextManager.initTrackingContext());
@@ -197,10 +227,9 @@ public static  T measure(String blockName, BlockTrackerConfig config, Supplie
         boolean isTrackerEnabled = actualConfig.isEnabled();
 
         TrackerItemContext trackerItemContext = new TrackerItemContext(
-            trackingCtx.getPointerDepth(), 
-            blockName
-        );
-        
+                trackingCtx.getPointerDepth(),
+                blockName);
+
         // Set threshold if configured
         if (actualConfig.getThreshold() > 0) {
             trackerItemContext.setThreshold(actualConfig.getThreshold());
@@ -213,7 +242,7 @@ public static  T measure(String blockName, BlockTrackerConfig config, Supplie
 
         if (isTrackerEnabled) {
             LOGGER.debug("{} ({})|{}| TrackerItemContext {} initiated, start tracking on code block: {}",
-                threadName, threadId, traceContextId, uuid, blockName);
+                    threadName, threadId, traceContextId, uuid, blockName);
             trackingCtx.addItemContext(uuid, trackerItemContext);
             trackingCtx.increasePointerDepth();
         }
@@ -225,19 +254,38 @@ public static  T measure(String blockName, BlockTrackerConfig config, Supplie
             result = codeBlock.get();
         } catch (Exception e) {
             LOGGER.warn("{} ({})|{}| Exception occurred in code block {}: {}",
-                threadName, threadId, traceContextId, blockName, e.getMessage());
+                    threadName, threadId, traceContextId, blockName, e.getMessage());
             throw e;
         } finally {
             long endTime = System.currentTimeMillis();
+            long executionTimeMs = endTime - startTime;
 
             if (isTrackerEnabled) {
-                LOGGER.debug("{} ({})|{}| TrackerItemContext {} finished tracking on code block: {}. Evaluating execution time...",
-                    threadName, threadId, traceContextId, uuid, blockName);
+                LOGGER.debug(
+                        "{} ({})|{}| TrackerItemContext {} finished tracking on code block: {}. Evaluating execution time...",
+                        threadName, threadId, traceContextId, uuid, blockName);
                 ChronoUnit trackingTimeUnit = actualConfig.getTimeUnit();
-                long executionTime = TimerNinjaUtil.convertFromMillis(endTime - startTime, trackingTimeUnit);
+                long executionTime = TimerNinjaUtil.convertFromMillis(executionTimeMs, trackingTimeUnit);
                 trackerItemContext.setExecutionTime(executionTime);
                 trackerItemContext.setTimeUnit(trackingTimeUnit);
-                LOGGER.debug("{} ({})|{}| TrackerItemContext: {}", threadName, threadId, traceContextId, trackerItemContext);
+                LOGGER.debug("{} ({})|{}| TrackerItemContext: {}", threadName, threadId, traceContextId,
+                        trackerItemContext);
+
+                // Record statistics if enabled
+                if (TimerNinjaConfiguration.getInstance().isStatisticsReportingEnabled()) {
+                    String trackerId = (actualConfig.getTrackerId() != null && !actualConfig.getTrackerId().isEmpty())
+                            ? actualConfig.getTrackerId()
+                            : "Block:" + blockName;
+                    int threshold = actualConfig.getThreshold();
+                    String parentTrackerId = trackingCtx.getPointerDepth() > 1 ? trackingCtx.getCurrentParentTrackerId()
+                            : null;
+
+                    StatisticsCollector.getInstance().recordExecution(
+                            trackerId, "TimerNinjaBlock", blockName,
+                            executionTimeMs, threshold, parentTrackerId);
+                    trackingCtx.setCurrentParentTrackerId(trackerId);
+                }
+
                 trackingCtx.decreasePointerDepth();
             }
 
@@ -246,7 +294,7 @@ public static  T measure(String blockName, BlockTrackerConfig config, Supplie
                 TimerNinjaUtil.logTimerContextTrace(trackingCtx);
                 localTrackingCtx.remove();
                 LOGGER.debug("{} ({})| TimerNinjaTracking context {} is completed and has been removed",
-                    threadName, threadId, traceContextId);
+                        threadName, threadId, traceContextId);
             }
         }
 
diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaConfiguration.java b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaConfiguration.java
index 3fe10d7..a1dc0a7 100644
--- a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaConfiguration.java
+++ b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaConfiguration.java
@@ -1,14 +1,24 @@
 package io.github.thanglequoc.timerninja;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 /**
  * The singleton to store TimerNinja configuration
- * */
+ */
 public class TimerNinjaConfiguration {
 
+    private static final Logger LOGGER = LoggerFactory.getLogger(TimerNinjaConfiguration.class);
+
     private static TimerNinjaConfiguration instance;
 
     private boolean enabledSystemOutLog;
 
+    // Statistics configuration
+    private boolean statisticsReportingEnabled = false;
+    private int statisticsBufferSize = 1000;
+    private boolean useFullMethodSignature = false;
+
     private TimerNinjaConfiguration() {
         enabledSystemOutLog = false;
     }
@@ -28,19 +38,139 @@ public static TimerNinjaConfiguration getInstance() {
     /**
      * By default, TimerNinja prints the result with Slf4 logging API.
* This option is for consumer that doesn't use any java logger provider.
- * Toggles the option to print timing trace results to System.out print stream in addition to the default logging using Slf4j. + * Toggles the option to print timing trace results to System.out print stream + * in addition to the default logging using Slf4j. * - * @param enabledSystemOutLogging true to enable printing to System.out, false otherwise. - * */ + * @param enabledSystemOutLogging true to enable printing to System.out, false + * otherwise. + */ public synchronized void toggleSystemOutLog(boolean enabledSystemOutLogging) { this.enabledSystemOutLog = enabledSystemOutLogging; } /** - * Check if TimerNinja will also print the log trace to System.out in addition to the default logging using Slf4j + * Check if TimerNinja will also print the log trace to System.out in addition + * to the default logging using Slf4j + * * @return flag indicates if System.out output is enabled - * */ + */ public boolean isSystemOutLogEnabled() { return enabledSystemOutLog; } + + // ==================== Statistics Configuration ==================== + + /** + * Enable or disable statistics collection. + * When enabled, execution times are recorded for later analysis. + * + * @param enabled true to enable statistics collection + */ + public synchronized void enableStatisticsReporting(boolean enabled) { + this.statisticsReportingEnabled = enabled; + if (enabled) { + LOGGER.info("Timer Ninja statistics reporting enabled"); + } + } + + /** + * Check if statistics reporting is enabled. + * + * @return true if statistics reporting is enabled + */ + public boolean isStatisticsReportingEnabled() { + return statisticsReportingEnabled; + } + + /** + * Set the maximum number of execution time samples to keep per method. + * Older samples are discarded when the buffer is full (FIFO eviction). + * + * @param size maximum buffer size (default: 1000) + */ + public synchronized void setStatisticsBufferSize(int size) { + if (size < 1) { + throw new IllegalArgumentException("Buffer size must be at least 1"); + } + this.statisticsBufferSize = size; + StatisticsCollector.getInstance().setMaxBufferSize(size); + } + + /** + * Get the maximum buffer size per method. + * + * @return the buffer size + */ + public int getStatisticsBufferSize() { + return statisticsBufferSize; + } + + /** + * Set whether to use full method signatures in reports. + * Default is false (shortened signatures like "methodName(Param1, Param2)"). + * + * @param useFull true to use full signatures, false for shortened + */ + public synchronized void setUseFullMethodSignature(boolean useFull) { + this.useFullMethodSignature = useFull; + } + + /** + * Check if full method signatures are used in reports. + * + * @return true if using full signatures + */ + public boolean isUseFullMethodSignature() { + return useFullMethodSignature; + } + + /** + * Get the StatisticsCollector instance. + * + * @return the statistics collector + */ + public StatisticsCollector getStatisticsCollector() { + return StatisticsCollector.getInstance(); + } + + /** + * Print statistics report to the configured logger. + */ + public void printStatisticsReport() { + String report = getStatisticsReportAsString("text"); + LOGGER.info("\n{}", report); + if (enabledSystemOutLog) { + System.out.println(report); + } + } + + /** + * Get statistics report as a formatted string. + * + * @param format output format: "text", "json", or "html" + * @return formatted report string + */ + public String getStatisticsReportAsString(String format) { + return StatisticsReportGenerator.generateReport( + StatisticsCollector.getInstance().getAllStatistics(), format); + } + + /** + * Get statistics report as bytes (for REST download). + * + * @param format output format: "text", "json", or "html" + * @return report as byte array + */ + public byte[] getStatisticsReportAsBytes(String format) { + return StatisticsReportGenerator.generateReportAsBytes( + StatisticsCollector.getInstance().getAllStatistics(), format); + } + + /** + * Reset all collected statistics. + */ + public void resetStatistics() { + StatisticsCollector.getInstance().reset(); + LOGGER.info("Timer Ninja statistics have been reset"); + } } diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaThreadContext.java b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaThreadContext.java index cbedf9d..e1ef86a 100644 --- a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaThreadContext.java +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaThreadContext.java @@ -9,42 +9,52 @@ import org.slf4j.LoggerFactory; /** - * Timer Ninja Thread context, which store the current timer tracking state and tracker execution trace - * */ + * Timer Ninja Thread context, which store the current timer tracking state and + * tracker execution trace + */ public class TimerNinjaThreadContext { /** * The logger class, to be used as logger in the TimeTrackingAspect code - * */ + */ public static Logger LOGGER = LoggerFactory.getLogger(TimerNinjaThreadContext.class); private final String traceContextId; /** * The creation time since this thread context starts - * */ + */ private final Instant creationTime; /** * The current pointer depth of the timer tracking context.
- * This depth increases for each nested annotated tracker method that is called within the current + * This depth increases for each nested annotated tracker method that is called + * within the current * method being evaluated. */ private int pointerDepth; /** - * Item context map to store the executing method being tracked. This contextMap will act as a stacktrace to be printed out later + * Item context map to store the executing method being tracked. This contextMap + * will act as a stacktrace to be printed out later * Key: The uuid generated for each tracker item context * Value: the tracker item context of the method being tracked - * */ + */ private Map itemContextMap; /** - * Basic constructor of a TimerNinjaThreadContext, with the following default values scheme:
+ * The current parent tracker ID for statistics hierarchy tracking. + * Used to establish parent-child relationships between tracked methods. + */ + private String currentParentTrackerId; + + /** + * Basic constructor of a TimerNinjaThreadContext, with the following default + * values scheme:
* - traceContextId: a random uuid * - creationTime: current instant.now() * - pointerDepth: root pointer, starts at 0 - * */ + */ public TimerNinjaThreadContext() { traceContextId = UUID.randomUUID().toString(); creationTime = Instant.now(); @@ -54,58 +64,85 @@ public TimerNinjaThreadContext() { /** * Get the current method pointer depth + * * @return the current pointer depth. Root is 0, one nested level is +1 - * */ + */ public int getPointerDepth() { return pointerDepth; } /** * Get the trace context id + * * @return The random generated trace context id in UUID format - * */ + */ public String getTraceContextId() { return traceContextId; } /** * Get the tracking context creation time + * * @return The creation time of the Timer Ninja thread context - * */ + */ public Instant getCreationTime() { return creationTime; } /** * Increase the pointer depth by one level + * * @return The increased pointer depth - * */ + */ public int increasePointerDepth() { return ++pointerDepth; } /** * Decrease the pointer depth by one level + * * @return The decreased pointer depth - * */ + */ public int decreasePointerDepth() { return --pointerDepth; } /** - * Get the current item context map. With the key is the tracker item context uuid, and value is the item itself + * Get the current item context map. With the key is the tracker item context + * uuid, and value is the item itself + * * @return The item context map - * */ + */ public Map getItemContextMap() { return itemContextMap; } /** * Add the tracking method to the item context map - * @param uuid The uuid of an item context - * @param trackerItemContext The tracker item context, represents a method invocation - * */ + * + * @param uuid The uuid of an item context + * @param trackerItemContext The tracker item context, represents a method + * invocation + */ public void addItemContext(String uuid, TrackerItemContext trackerItemContext) { this.itemContextMap.put(uuid, trackerItemContext); } + + /** + * Get the current parent tracker ID for statistics hierarchy. + * + * @return the current parent tracker ID + */ + public String getCurrentParentTrackerId() { + return currentParentTrackerId; + } + + /** + * Set the current parent tracker ID for statistics hierarchy. + * + * @param currentParentTrackerId the tracker ID of the current parent + */ + public void setCurrentParentTrackerId(String currentParentTrackerId) { + this.currentParentTrackerId = currentParentTrackerId; + } } diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaTracker.java b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaTracker.java index 7c4fdcc..3e05b85 100644 --- a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaTracker.java +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaTracker.java @@ -7,42 +7,59 @@ import java.time.temporal.ChronoUnit; /** - * Annotate any Java method with this annotation to track the total execution time of the method.
- * This annotation also supports nested tracking. If the annotated method is invoked from a parent method that is also annotated with - * {@code @TimerNinjaTracker}, then the execution time of the method will be added to the existing tracking context. + * Annotate any Java method with this annotation to track the total execution + * time of the method.
+ * This annotation also supports nested tracking. If the annotated method is + * invoked from a parent method that is also annotated with + * {@code @TimerNinjaTracker}, then the execution time of the method will be + * added to the existing tracking context. */ @Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.CONSTRUCTOR, ElementType.METHOD}) +@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD }) public @interface TimerNinjaTracker { /** * The time unit to use for the tracker. * Supported time units: second, millisecond (default), microsecond + * * @return The time unit of the tracker - * */ + */ ChronoUnit timeUnit() default ChronoUnit.MILLIS; /** * Determine if this tracker should be active * Set to false will disable this tracker from the overall tracking trace result + * * @return boolean flag indicates if this tracker is active - * */ + */ boolean enabled() default true; /** - * Determine if this tracker should also include the argument information passed to the method being tracked + * Determine if this tracker should also include the argument information passed + * to the method being tracked * Default is false * - * @return true if the tracker should include argument information, false otherwise - * */ + * @return true if the tracker should include argument information, false + * otherwise + */ boolean includeArgs() default false; /** - * Set the threshold of the tracker, if the execution time of the method is less than the threshold, + * Set the threshold of the tracker, if the execution time of the method is less + * than the threshold, * the tracker will not be included in the tracking result. * Default is -1, which means no threshold is set. * * @return the threshold definition for the current tracker - * */ + */ int threshold() default -1; + + /** + * Unique identifier for this tracker in statistics. + * If empty, defaults to "ClassName.methodName". + * Useful when same method signature exists in different classes. + * + * @return the custom tracker ID for statistics + */ + String trackerId() default ""; } diff --git a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaUtil.java b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaUtil.java index 036a290..135860a 100644 --- a/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaUtil.java +++ b/src/main/aspectj/io/github/thanglequoc/timerninja/TimerNinjaUtil.java @@ -18,23 +18,26 @@ /** * General utility class of TimerNinja library - * */ + */ public class TimerNinjaUtil { private static Logger LOGGER = LoggerFactory.getLogger(TimerNinjaUtil.class); /** - * The timer ninja util is a util class with static method, so instance creation is not allowed on this util class - * */ + * The timer ninja util is a util class with static method, so instance creation + * is not allowed on this util class + */ private TimerNinjaUtil() { } /** - * Determines whether the {@link TimerNinjaTracker} annotation is enabled on the given method. + * Determines whether the {@link TimerNinjaTracker} annotation is enabled on the + * given method. * * @param methodSignature the AspectJ {@code MethodSignature} representing * the annotated method; must not be {@code null} - * @return {@code true} if the {@link TimerNinjaTracker#enabled()} flag is set to {@code true} + * @return {@code true} if the {@link TimerNinjaTracker#enabled()} flag is set + * to {@code true} * on the method, {@code false} otherwise * @throws IllegalArgumentException if {@code methodSignature} is {@code null} */ @@ -48,28 +51,35 @@ public static boolean isTimerNinjaTrackerEnabled(MethodSignature methodSignature } /** - * Determines whether the {@link TimerNinjaTracker} annotation is enabled on the given constructor. + * Determines whether the {@link TimerNinjaTracker} annotation is enabled on the + * given constructor. * * @param constructorSignature the AspectJ {@code ConstructorSignature} - * representing the annotated constructor; must not be {@code null} - * @return {@code true} if the {@link TimerNinjaTracker#enabled()} flag is set to {@code true} + * representing the annotated constructor; must not + * be {@code null} + * @return {@code true} if the {@link TimerNinjaTracker#enabled()} flag is set + * to {@code true} * on the constructor, {@code false} otherwise - * @throws IllegalArgumentException if {@code constructorSignature} is {@code null} + * @throws IllegalArgumentException if {@code constructorSignature} is + * {@code null} */ public static boolean isTimerNinjaTrackerEnabled(ConstructorSignature constructorSignature) { if (constructorSignature == null) { throw new IllegalArgumentException("ConstructorSignature must be present"); } - TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor().getAnnotation(TimerNinjaTracker.class); + TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor() + .getAnnotation(TimerNinjaTracker.class); return annotation.enabled(); } /** - * Retrieves the threshold value defined in the {@link TimerNinjaTracker} annotation on the given method. + * Retrieves the threshold value defined in the {@link TimerNinjaTracker} + * annotation on the given method. * * @param methodSignature the AspectJ {@code MethodSignature} representing * the annotated method; must not be {@code null} - * @return the threshold value configured via {@link TimerNinjaTracker#threshold()} + * @return the threshold value configured via + * {@link TimerNinjaTracker#threshold()} * @throws IllegalArgumentException if {@code methodSignature} is {@code null} */ public static int getThreshold(MethodSignature methodSignature) { @@ -81,19 +91,23 @@ public static int getThreshold(MethodSignature methodSignature) { } /** - * Retrieves the threshold value defined in the {@link TimerNinjaTracker} annotation on the given constructor. + * Retrieves the threshold value defined in the {@link TimerNinjaTracker} + * annotation on the given constructor. * * @param constructorSignature the AspectJ {@code ConstructorSignature} * representing the annotated constructor; * must not be {@code null} - * @return the threshold value configured via {@link TimerNinjaTracker#threshold()} - * @throws IllegalArgumentException if {@code constructorSignature} is {@code null} + * @return the threshold value configured via + * {@link TimerNinjaTracker#threshold()} + * @throws IllegalArgumentException if {@code constructorSignature} is + * {@code null} */ public static int getThreshold(ConstructorSignature constructorSignature) { if (constructorSignature == null) { throw new IllegalArgumentException("ConstructorSignature must be present"); } - TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor().getAnnotation(TimerNinjaTracker.class); + TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor() + .getAnnotation(TimerNinjaTracker.class); return annotation.threshold(); } @@ -101,17 +115,22 @@ public static int getThreshold(ConstructorSignature constructorSignature) { * Determines whether argument logging is enabled for the given constructor * annotated with {@link TimerNinjaTracker}. * - * @param constructorSignature the AspectJ {@code ConstructorSignature} representing - * the annotated constructor; must not be {@code null} - * @return {@code true} if the {@link TimerNinjaTracker#includeArgs()} flag is enabled + * @param constructorSignature the AspectJ {@code ConstructorSignature} + * representing + * the annotated constructor; must not be + * {@code null} + * @return {@code true} if the {@link TimerNinjaTracker#includeArgs()} flag is + * enabled * on the constructor, {@code false} otherwise - * @throws IllegalArgumentException if {@code constructorSignature} is {@code null} + * @throws IllegalArgumentException if {@code constructorSignature} is + * {@code null} */ public static boolean isArgsIncluded(ConstructorSignature constructorSignature) { if (constructorSignature == null) { throw new IllegalArgumentException("ConstructorSignature must be present"); } - TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor().getAnnotation(TimerNinjaTracker.class); + TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor() + .getAnnotation(TimerNinjaTracker.class); return annotation.includeArgs(); } @@ -121,7 +140,8 @@ public static boolean isArgsIncluded(ConstructorSignature constructorSignature) * * @param methodSignature the AspectJ {@code MethodSignature} representing * the annotated method; must not be {@code null} - * @return {@code true} if the {@link TimerNinjaTracker#includeArgs()} flag is enabled + * @return {@code true} if the {@link TimerNinjaTracker#includeArgs()} flag is + * enabled * on the method, {@code false} otherwise * @throws IllegalArgumentException if {@code methodSignature} is {@code null} */ @@ -134,7 +154,8 @@ public static boolean isArgsIncluded(MethodSignature methodSignature) { } /** - * Retrieves the {@link ChronoUnit} time unit defined in the {@link TimerNinjaTracker} annotation on the given method. + * Retrieves the {@link ChronoUnit} time unit defined in the + * {@link TimerNinjaTracker} annotation on the given method. * * @param methodSignature the AspectJ {@code MethodSignature} representing * the annotated method; must not be {@code null} @@ -151,35 +172,147 @@ public static ChronoUnit getTrackingTimeUnit(MethodSignature methodSignature) { } /** - * Retrieves the {@link ChronoUnit} time unit defined in the {@link TimerNinjaTracker} annotation on the given constructor. + * Retrieves the {@link ChronoUnit} time unit defined in the + * {@link TimerNinjaTracker} annotation on the given constructor. * * @param constructorSignature the AspectJ {@code ConstructorSignature} * representing the annotated constructor; * must not be {@code null} * @return the time unit configured via {@link TimerNinjaTracker#timeUnit()} - * @throws IllegalArgumentException if {@code constructorSignature} is {@code null} + * @throws IllegalArgumentException if {@code constructorSignature} is + * {@code null} */ public static ChronoUnit getTrackingTimeUnit(ConstructorSignature constructorSignature) { if (constructorSignature == null) { throw new IllegalArgumentException("ConstructorSignature must be present"); } - TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor().getAnnotation(TimerNinjaTracker.class); + TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor() + .getAnnotation(TimerNinjaTracker.class); return annotation.timeUnit(); } + /** + * Retrieves the tracker ID for a method. If no custom trackerId is specified + * in the annotation, generates a default ID in the format + * "ClassName.methodName". + * + * @param methodSignature the AspectJ {@code MethodSignature} + * @return the tracker ID for statistics + */ + public static String getTrackerId(MethodSignature methodSignature) { + if (methodSignature == null) { + throw new IllegalArgumentException("MethodSignature must be present"); + } + + TimerNinjaTracker annotation = methodSignature.getMethod().getAnnotation(TimerNinjaTracker.class); + String customId = annotation.trackerId(); + + if (customId != null && !customId.isEmpty()) { + return customId; + } + + // Generate default: ClassName.methodName + String className = methodSignature.getDeclaringType().getSimpleName(); + String methodName = methodSignature.getName(); + return className + "." + methodName; + } + + /** + * Retrieves the tracker ID for a constructor. If no custom trackerId is + * specified + * in the annotation, generates a default ID in the format "ClassName.ClassName" + * (constructor). + * + * @param constructorSignature the AspectJ {@code ConstructorSignature} + * @return the tracker ID for statistics + */ + public static String getTrackerId(ConstructorSignature constructorSignature) { + if (constructorSignature == null) { + throw new IllegalArgumentException("ConstructorSignature must be present"); + } + + TimerNinjaTracker annotation = (TimerNinjaTracker) constructorSignature.getConstructor() + .getAnnotation(TimerNinjaTracker.class); + String customId = annotation.trackerId(); + + if (customId != null && !customId.isEmpty()) { + return customId; + } + + // Generate default: ClassName. + String className = constructorSignature.getDeclaringType().getSimpleName(); + return className + "."; + } + + /** + * Returns a shortened method signature without modifiers and parameter names. + * Example: "processPayment(User, int)" + * + * @param methodSignature the method signature + * @return shortened signature string + */ + public static String getShortenedMethodSignature(MethodSignature methodSignature) { + if (methodSignature == null) { + throw new IllegalArgumentException("MethodSignature must be present"); + } + + StringBuilder sb = new StringBuilder(); + sb.append(methodSignature.getName()).append("("); + + Class[] parameterClasses = methodSignature.getParameterTypes(); + for (int i = 0; i < parameterClasses.length; i++) { + sb.append(parameterClasses[i].getSimpleName()); + if (i != parameterClasses.length - 1) { + sb.append(", "); + } + } + sb.append(")"); + return sb.toString(); + } + + /** + * Returns a shortened constructor signature without modifiers and parameter + * names. + * Example: "MyClass(String, int)" + * + * @param constructorSignature the constructor signature + * @return shortened signature string + */ + public static String getShortenedConstructorSignature(ConstructorSignature constructorSignature) { + if (constructorSignature == null) { + throw new IllegalArgumentException("ConstructorSignature must be present"); + } + + StringBuilder sb = new StringBuilder(); + sb.append(constructorSignature.getDeclaringType().getSimpleName()).append("("); + + Class[] parameterClasses = constructorSignature.getParameterTypes(); + for (int i = 0; i < parameterClasses.length; i++) { + sb.append(parameterClasses[i].getSimpleName()); + if (i != parameterClasses.length - 1) { + sb.append(", "); + } + } + sb.append(")"); + return sb.toString(); + } + /** * Builds a human-readable representation of a method signature, including its * modifiers, return type, method name, and parameter list. *

* The output format resembles a simplified Java method declaration. * Example: + * *

      * public static String prettyGetMethodSignature(MethodSignature methodSignature)
      * 
* - * @param methodSignature the AspectJ {@code MethodSignature} to render; must not be {@code null} - * @return a formatted string containing the method modifiers, return type, name, + * @param methodSignature the AspectJ {@code MethodSignature} to render; must + * not be {@code null} + * @return a formatted string containing the method modifiers, return type, + * name, * and parameters * @throws IllegalArgumentException if {@code methodSignature} is {@code null} */ @@ -217,20 +350,24 @@ public static String prettyGetMethodSignature(MethodSignature methodSignature) { } /** - * Builds a human-readable representation of a constructor signature, including its + * Builds a human-readable representation of a constructor signature, including + * its * modifiers, class name, and parameter list. *

* The output format resembles a simplified Java constructor declaration. * Example: + * *

      * public TrackerItemContext(String abc)
      * 
* - * @param constructorSignature the AspectJ {@code ConstructorSignature} to render; + * @param constructorSignature the AspectJ {@code ConstructorSignature} to + * render; * must not be {@code null} * @return a formatted string containing the constructor modifiers, class name, * and parameters - * @throws IllegalArgumentException if {@code constructorSignature} is {@code null} + * @throws IllegalArgumentException if {@code constructorSignature} is + * {@code null} */ public static String prettyGetConstructorSignature(ConstructorSignature constructorSignature) { if (constructorSignature == null) { @@ -269,13 +406,16 @@ public static String prettyGetConstructorSignature(ConstructorSignature construc * name={value}. *

* Example output: + * *

      * user={name='John Doe', age=30}, amount={500}
      * 
* - * @param joinPoint the AspectJ {@code JoinPoint} containing argument and parameter + * @param joinPoint the AspectJ {@code JoinPoint} containing argument and + * parameter * name information; must not be {@code null} - * @return a formatted string listing parameter names and their corresponding values + * @return a formatted string listing parameter names and their corresponding + * values * @throws IllegalArgumentException if {@code joinPoint} is {@code null} */ public static String prettyGetArguments(JoinPoint joinPoint) { @@ -285,7 +425,7 @@ public static String prettyGetArguments(JoinPoint joinPoint) { StringBuilder sb = new StringBuilder(); Object[] args = joinPoint.getArgs(); - String[] names = ((CodeSignature)joinPoint.getSignature()).getParameterNames(); + String[] names = ((CodeSignature) joinPoint.getSignature()).getParameterNames(); for (int i = 0; i < args.length; i++) { sb.append(names[i]); sb.append("={"); @@ -299,15 +439,16 @@ public static String prettyGetArguments(JoinPoint joinPoint) { } /** - * Logs a detailed execution trace for the provided {@link TimerNinjaThreadContext}. + * Logs a detailed execution trace for the provided + * {@link TimerNinjaThreadContext}. *

* The output includes: *

    - *
  • the timer-ninja trace context ID
  • - *
  • the UTC creation timestamp of the tracking context
  • - *
  • a formatted breakdown of each tracked method or constructor, - * including indentation, arguments (if enabled), execution times, - * and threshold exceed indicators
  • + *
  • the timer-ninja trace context ID
  • + *
  • the UTC creation timestamp of the tracking context
  • + *
  • a formatted breakdown of each tracked method or constructor, + * including indentation, arguments (if enabled), execution times, + * and threshold exceed indicators
  • *
* *

@@ -316,9 +457,11 @@ public static String prettyGetArguments(JoinPoint joinPoint) { * configuration defined in {@link TimerNinjaConfiguration}. * * @param timerNinjaThreadContext the thread-local tracking context containing - * all recorded {@link TrackerItemContext} entries; + * all recorded {@link TrackerItemContext} + * entries; * must not be {@code null} - * @throws IllegalArgumentException if {@code timerNinjaThreadContext} is {@code null} + * @throws IllegalArgumentException if {@code timerNinjaThreadContext} is + * {@code null} */ public static void logTimerContextTrace(TimerNinjaThreadContext timerNinjaThreadContext) { if (timerNinjaThreadContext == null) { @@ -334,27 +477,29 @@ public static void logTimerContextTrace(TimerNinjaThreadContext timerNinjaThread return; } - // First, filter out items that are within threshold and check if any items should be logged + // First, filter out items that are within threshold and check if any items + // should be logged List itemsToLog = new ArrayList<>(); for (TrackerItemContext item : timerNinjaThreadContext.getItemContextMap().values()) { boolean shouldLogItem = true; - + // Item has threshold enabled and is within limit - don't log it if (item.isEnableThreshold() && item.getExecutionTime() < item.getThreshold()) { shouldLogItem = false; } - + if (shouldLogItem) { itemsToLog.add(item); } } - // If all items are filtered out by threshold, provide a summary instead of empty trace + // If all items are filtered out by threshold, provide a summary instead of + // empty trace if (itemsToLog.isEmpty()) { long totalExecutionTime = 0; long minTime = Long.MAX_VALUE; long maxTime = 0; - + for (TrackerItemContext item : timerNinjaThreadContext.getItemContextMap().values()) { totalExecutionTime += item.getExecutionTime(); if (item.getExecutionTime() < minTime) { @@ -364,9 +509,9 @@ public static void logTimerContextTrace(TimerNinjaThreadContext timerNinjaThread maxTime = item.getExecutionTime(); } } - - logMessage("All {} tracked items within threshold (min: {} ms, max: {} ms, total: {} ms)", - timerNinjaThreadContext.getItemContextMap().size(), minTime, maxTime, totalExecutionTime); + + logMessage("All {} tracked items within threshold (min: {} ms, max: {} ms, total: {} ms)", + timerNinjaThreadContext.getItemContextMap().size(), minTime, maxTime, totalExecutionTime); return; } @@ -375,12 +520,12 @@ public static void logTimerContextTrace(TimerNinjaThreadContext timerNinjaThread for (TrackerItemContext item : itemsToLog) { /* - * Breakdown msg format - {}{}: Indent + Method name - - Args: [{}]: Args information (if included?) - - {} {}: Execution time + unit - ¤ [Threshold Exceed !!: {} ms]: If threshold exceeded - * */ + * Breakdown msg format + * {}{}: Indent + Method name + * - Args: [{}]: Args information (if included?) + * - {} {}: Execution time + unit + * ¤ [Threshold Exceed !!: {} ms]: If threshold exceeded + */ List argList = new ArrayList<>(); StringBuilder msgFormat = new StringBuilder(); @@ -422,7 +567,7 @@ public static void logTimerContextTrace(TimerNinjaThreadContext timerNinjaThread * * @param format the message format string, supporting '{}' placeholders * for argument substitution - * @param args the arguments to be substituted into the format string + * @param args the arguments to be substituted into the format string */ private static void logMessage(String format, Object... args) { LOGGER.info(format, args); @@ -439,7 +584,8 @@ private static void logMessage(String format, Object... args) { * The prefix consists of spaces proportional to the pointer depth, followed * by the "|-- " marker. * - * @param pointerDepth the depth of the method/constructor in the trace hierarchy; + * @param pointerDepth the depth of the method/constructor in the trace + * hierarchy; * zero indicates no indentation * @return a formatted string containing leading spaces and the "|-- " prefix, * suitable for aligning nested method traces @@ -450,7 +596,7 @@ private static String generateIndent(int pointerDepth) { } int spaceCount = pointerDepth * 2; StringBuilder sb = new StringBuilder(); - for (int i = 1; i<= spaceCount; i++) { + for (int i = 1; i <= spaceCount; i++) { sb.append(" "); } sb.append("|-- "); @@ -476,43 +622,45 @@ public static boolean isThresholdExceeded(TrackerItemContext item) { *

* Supported conversions: *

    - *
  • {@link ChronoUnit#MILLIS}: returns the same value
  • - *
  • {@link ChronoUnit#SECONDS}: converts milliseconds to seconds
  • - *
  • {@link ChronoUnit#MICROS}: converts milliseconds to microseconds
  • + *
  • {@link ChronoUnit#MILLIS}: returns the same value
  • + *
  • {@link ChronoUnit#SECONDS}: converts milliseconds to seconds
  • + *
  • {@link ChronoUnit#MICROS}: converts milliseconds to microseconds
  • *
* - * @param timeInMillis the time value in milliseconds + * @param timeInMillis the time value in milliseconds * @param unitToConvert the target {@link ChronoUnit} to convert the time into; * must be one of the supported units * @return the converted time in the specified {@code ChronoUnit} - * @throws IllegalStateException if {@code unitToConvert} is not supported by this method + * @throws IllegalStateException if {@code unitToConvert} is not supported by + * this method */ public static long convertFromMillis(long timeInMillis, ChronoUnit unitToConvert) { - if (ChronoUnit.MILLIS.equals(unitToConvert)) { - return timeInMillis; - } - else if (ChronoUnit.SECONDS.equals(unitToConvert)) { - return timeInMillis / 1000; - } else if (ChronoUnit.MICROS.equals(unitToConvert)) { - return timeInMillis * 1000; - } - throw new IllegalStateException("Time unit not supported"); + if (ChronoUnit.MILLIS.equals(unitToConvert)) { + return timeInMillis; + } else if (ChronoUnit.SECONDS.equals(unitToConvert)) { + return timeInMillis / 1000; + } else if (ChronoUnit.MICROS.equals(unitToConvert)) { + return timeInMillis * 1000; + } + throw new IllegalStateException("Time unit not supported"); } /** - * Returns a short, human-readable string representing the given {@link ChronoUnit}. + * Returns a short, human-readable string representing the given + * {@link ChronoUnit}. *

* Supported units: *

    - *
  • {@link ChronoUnit#MILLIS}: "ms"
  • - *
  • {@link ChronoUnit#SECONDS}: "s"
  • - *
  • {@link ChronoUnit#MICROS}: "µs"
  • + *
  • {@link ChronoUnit#MILLIS}: "ms"
  • + *
  • {@link ChronoUnit#SECONDS}: "s"
  • + *
  • {@link ChronoUnit#MICROS}: "µs"
  • *
* * @param chronoUnit the {@link ChronoUnit} to get the short display unit for; * must be one of the supported units * @return the short string representation of the time unit - * @throws IllegalStateException if {@code chronoUnit} is not supported by this method + * @throws IllegalStateException if {@code chronoUnit} is not supported by this + * method */ private static String getPresentationUnit(ChronoUnit chronoUnit) { if (ChronoUnit.MILLIS.equals(chronoUnit)) { @@ -531,6 +679,7 @@ private static String getPresentationUnit(ChronoUnit chronoUnit) { *

* The output pattern is {@code yyyy-MM-dd'T'HH:mm:ss.SSS'Z'}. * Example output: + * *

      * 2023-03-27T11:24:46.948Z
      * 
@@ -541,7 +690,7 @@ private static String getPresentationUnit(ChronoUnit chronoUnit) { */ private static String toUTCTimestampString(Instant instant) { return DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - .withZone(ZoneOffset.UTC) - .format(instant); + .withZone(ZoneOffset.UTC) + .format(instant); } } diff --git a/src/test/aspectj/io/github/thanglequoc/timerninja/TimerNinjaIntegrationTest.java b/src/test/aspectj/io/github/thanglequoc/timerninja/TimerNinjaIntegrationTest.java index c2a9be1..a1b5683 100644 --- a/src/test/aspectj/io/github/thanglequoc/timerninja/TimerNinjaIntegrationTest.java +++ b/src/test/aspectj/io/github/thanglequoc/timerninja/TimerNinjaIntegrationTest.java @@ -73,7 +73,8 @@ public void testTrackingOnMethods() { assertTrue(transferContextStart > 0, "Should find requestMoneyTransfer in output"); // Verify requestMoneyTransfer method is tracked with threshold exceed - assertTrue(formattedMessages.get(transferContextStart).contains("public void requestMoneyTransfer(int sourceUserId, int targetUserId, int amount)")); + assertTrue(formattedMessages.get(transferContextStart) + .contains("public void requestMoneyTransfer(int sourceUserId, int targetUserId, int amount)")); assertTrue(formattedMessages.get(transferContextStart).contains("[Threshold Exceed !!:")); // Verify nested method calls are tracked @@ -84,11 +85,16 @@ public void testTrackingOnMethods() { boolean foundNotifyViaEmail = false; for (String message : formattedMessages) { - if (message.contains("public User findUser(int userId)")) foundFindUser = true; - if (message.contains("public void increaseAmount(User user, int amount)")) foundIncreaseAmount = true; - if (message.contains("public void notify(User user)")) foundNotify = true; - if (message.contains("private void notifyViaSMS(User user)")) foundNotifyViaSMS = true; - if (message.contains("private void notifyViaEmail(User user)")) foundNotifyViaEmail = true; + if (message.contains("public User findUser(int userId)")) + foundFindUser = true; + if (message.contains("public void increaseAmount(User user, int amount)")) + foundIncreaseAmount = true; + if (message.contains("public void notify(User user)")) + foundNotify = true; + if (message.contains("private void notifyViaSMS(User user)")) + foundNotifyViaSMS = true; + if (message.contains("private void notifyViaEmail(User user)")) + foundNotifyViaEmail = true; } assertTrue(foundFindUser, "Should find findUser call"); @@ -116,7 +122,8 @@ public void testTrackingOnMethods_MethodWithinExecutionThreshold() { List formattedMessages = logCaptureExtension.getFormattedMessages(); assertFalse(formattedMessages.isEmpty()); - // When all tracked items are within threshold, a summary is shown instead of empty trace + // When all tracked items are within threshold, a summary is shown instead of + // empty trace assertTrue(formattedMessages.get(0).startsWith("Timer Ninja trace context id:")); assertTrue(formattedMessages.get(0).contains("Trace timestamp:")); assertTrue(formattedMessages.get(1).startsWith("All ")); @@ -125,9 +132,102 @@ public void testTrackingOnMethods_MethodWithinExecutionThreshold() { assertTrue(formattedMessages.get(1).contains("max:")); assertTrue(formattedMessages.get(1).contains("total:")); - // The notification service is called but there is no detailed trace output printing out, + // The notification service is called but there is no detailed trace output + // printing out, // this is the expected behavior because all methods met the threshold setting verify(notificationService, times(1)).notify(user); } + @Test + public void testTrackingOnStaticMethod() { + BankService.printBankInfo(); + + List formattedMessages = logCaptureExtension.getFormattedMessages(); + assertFalse(formattedMessages.isEmpty()); + + // Verify that the static method is tracked + boolean foundStaticMethod = false; + for (String message : formattedMessages) { + if (message.contains("public static void printBankInfo()")) { + foundStaticMethod = true; + break; + } + } + assertTrue(foundStaticMethod, "Should find printBankInfo static method in output"); + } + + @Test + public void testTrackingOnStaticMethodWithArgs() { + BankService.printWithArgs("hello", 5); + + List formattedMessages = logCaptureExtension.getFormattedMessages(); + assertFalse(formattedMessages.isEmpty()); + + boolean foundWithArgs = false; + for (String message : formattedMessages) { + // Check for method name and argument values in the format name={value} + if (message.contains("printWithArgs") && + message.contains("message={hello}") && + message.contains("count={5}")) { + foundWithArgs = true; + break; + } + } + assertTrue(foundWithArgs, "Should find printWithArgs with arguments in output"); + } + + @Test + public void testTrackingOnStaticMethodException() { + try { + BankService.printAndThrow(); + } catch (RuntimeException e) { + // Expected + } + + List formattedMessages = logCaptureExtension.getFormattedMessages(); + assertFalse(formattedMessages.isEmpty()); + + boolean foundExceptionMethod = false; + for (String message : formattedMessages) { + if (message.contains("public static void printAndThrow()")) { + foundExceptionMethod = true; + break; + } + } + assertTrue(foundExceptionMethod, "Should track method even if it fails"); + } + + @Test + public void testTrackingOnNestedStaticMethods() { + BankService.nestedStaticMethodA(); + + List formattedMessages = logCaptureExtension.getFormattedMessages(); + assertFalse(formattedMessages.isEmpty()); + + boolean foundNestedA = false; + boolean foundNestedB = false; + + // Find the context for nestedStaticMethodA + int indexA = -1; + for (int i = 0; i < formattedMessages.size(); i++) { + if (formattedMessages.get(i).contains("public static void nestedStaticMethodA()")) { + foundNestedA = true; + indexA = i; + break; + } + } + + assertTrue(foundNestedA, "Should find nestedStaticMethodA"); + + // Look for nestedStaticMethodB after A + for (int i = indexA + 1; i < formattedMessages.size(); i++) { + if (formattedMessages.get(i).contains("public static void nestedStaticMethodB()")) { + foundNestedB = true; + break; + } + } + + assertTrue(foundNestedB, "Should find nestedStaticMethodB nested after A"); + } + } diff --git a/src/test/aspectj/io/github/thanglequoc/timerninja/servicesample/BankService.java b/src/test/aspectj/io/github/thanglequoc/timerninja/servicesample/BankService.java index 04cc370..a8bea69 100644 --- a/src/test/aspectj/io/github/thanglequoc/timerninja/servicesample/BankService.java +++ b/src/test/aspectj/io/github/thanglequoc/timerninja/servicesample/BankService.java @@ -14,8 +14,8 @@ import java.time.temporal.ChronoUnit; /** -* Dummy service class for testing purpose -* */ + * Dummy service class for testing purpose + */ public class BankService { private BalanceService balanceService; @@ -37,9 +37,10 @@ public BankService() { } } - /* Test method to simulate transfer money - * The threshold setting must always exceed - * */ + /* + * Test method to simulate transfer money + * The threshold setting must always exceed + */ @TimerNinjaTracker(threshold = 200) public void requestMoneyTransfer(int sourceUserId, int targetUserId, int amount) { User sourceUser = userService.findUser(sourceUserId); @@ -62,7 +63,8 @@ public void payWithCard(int userId, BankCard card, int amount) { /** * Test method to simulate loan application processing. - * This method demonstrates the integration between @TimerNinjaTracker annotation + * This method demonstrates the integration between @TimerNinjaTracker + * annotation * and TimerNinjaBlock code block tracking. * The overall method is tracked with @TimerNinjaTracker, while specific phases * are tracked using TimerNinjaBlock.measure() to show nested tracking. @@ -70,33 +72,33 @@ public void payWithCard(int userId, BankCard card, int amount) { @TimerNinjaTracker(includeArgs = true, threshold = 100) public void processLoanApplication(int userId, double loanAmount, int termMonths) { User user = userService.findUser(userId); - + // Phase 1: Credit check - tracked with code block tracking TimerNinjaBlock.measure("credit score check", () -> { simulateDelay(60); // Simulate credit check }); - + // Phase 2: Income verification - tracked with code block tracking TimerNinjaBlock.measure("income verification", () -> { simulateDelay(80); // Simulate income verification }); - + // Phase 3: Risk assessment - tracked with code block tracking and custom config BlockTrackerConfig riskConfig = new BlockTrackerConfig() - .setTimeUnit(ChronoUnit.MILLIS) - .setThreshold(30); - + .setTimeUnit(ChronoUnit.MILLIS) + .setThreshold(30); + TimerNinjaBlock.measure("risk assessment", riskConfig, () -> { simulateDelay(40); // Simulate risk assessment }); - + // Phase 4: Final approval - tracked with code block tracking String approvalStatus = TimerNinjaBlock.measure("final approval", () -> { simulateDelay(50); // Simulate approval process return "APPROVED"; }); } - + /** * Helper method to simulate processing delay for demonstration purposes. */ @@ -109,4 +111,55 @@ private void simulateDelay(int milliseconds) { } } + /** + * Test static method + */ + @TimerNinjaTracker(threshold = 0) + public static void printBankInfo() { + try { + System.out.println("Bank Info: TimerNinja Bank"); + Thread.sleep(50); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + @TimerNinjaTracker(includeArgs = true, threshold = 0) + public static void printWithArgs(String message, int count) { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + @TimerNinjaTracker(threshold = 0) + public static void printAndThrow() { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + throw new RuntimeException("Simulated static error"); + } + + @TimerNinjaTracker(threshold = 0) + public static void nestedStaticMethodA() { + try { + Thread.sleep(20); + nestedStaticMethodB(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + @TimerNinjaTracker(threshold = 0) + public static void nestedStaticMethodB() { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } diff --git a/src/test/java/io/github/thanglequoc/timerninja/MethodStatisticsTest.java b/src/test/java/io/github/thanglequoc/timerninja/MethodStatisticsTest.java new file mode 100644 index 0000000..a72c0c6 --- /dev/null +++ b/src/test/java/io/github/thanglequoc/timerninja/MethodStatisticsTest.java @@ -0,0 +1,155 @@ +package io.github.thanglequoc.timerninja; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MethodStatistics class. + */ +class MethodStatisticsTest { + + private MethodStatistics stats; + + @BeforeEach + void setUp() { + stats = new MethodStatistics("TestClass.testMethod", "TestClass", "testMethod()", 100); + } + + @Test + @DisplayName("Should record execution time correctly") + void testRecordExecution() { + stats.recordExecution(100, -1); + stats.recordExecution(200, -1); + stats.recordExecution(150, -1); + + assertEquals(3, stats.getSampleCount()); + List times = stats.getExecutionTimesCopy(); + assertTrue(times.contains(100L)); + assertTrue(times.contains(200L)); + assertTrue(times.contains(150L)); + } + + @Test + @DisplayName("Should calculate average correctly") + void testCalculateAverage() { + stats.recordExecution(100, -1); + stats.recordExecution(200, -1); + stats.recordExecution(300, -1); + + assertEquals(200, stats.calculateAverage()); + } + + @Test + @DisplayName("Should calculate percentiles correctly") + void testCalculatePercentile() { + // Add 10 samples: 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 + for (int i = 1; i <= 10; i++) { + stats.recordExecution(i * 10, -1); + } + + assertEquals(50, stats.calculatePercentile(50)); // p50 = 50 + assertEquals(90, stats.calculatePercentile(90)); // p90 = 90 + assertEquals(100, stats.calculatePercentile(95)); // p95 = 100 + } + + @Test + @DisplayName("Should track min and max correctly") + void testMinMax() { + stats.recordExecution(50, -1); + stats.recordExecution(100, -1); + stats.recordExecution(25, -1); + stats.recordExecution(75, -1); + + assertEquals(25, stats.getMin()); + assertEquals(100, stats.getMax()); + } + + @Test + @DisplayName("Should track threshold exceeded and within counts") + void testThresholdTracking() { + // Threshold = 100ms + stats.recordExecution(50, 100); // within + stats.recordExecution(150, 100); // exceeded + stats.recordExecution(100, 100); // within (equal) + stats.recordExecution(200, 100); // exceeded + + assertEquals(2, stats.getThresholdExceededCount()); + assertEquals(2, stats.getThresholdWithinCount()); + assertEquals(100, stats.getThresholdMs()); + } + + @Test + @DisplayName("Should implement FIFO eviction when buffer is full") + void testFifoEviction() { + // Create a small buffer of 3 + MethodStatistics smallStats = new MethodStatistics("test", "Test", "test()", 3); + + smallStats.recordExecution(10, -1); + smallStats.recordExecution(20, -1); + smallStats.recordExecution(30, -1); + assertEquals(3, smallStats.getSampleCount()); + + // Add 4th item - should evict the first (10) + smallStats.recordExecution(40, -1); + assertEquals(3, smallStats.getSampleCount()); + + List times = smallStats.getExecutionTimesCopy(); + assertFalse(times.contains(10L)); + assertTrue(times.contains(20L)); + assertTrue(times.contains(30L)); + assertTrue(times.contains(40L)); + } + + @Test + @DisplayName("Should return 0 when no samples recorded") + void testEmptyStatistics() { + assertEquals(0, stats.calculateAverage()); + assertEquals(0, stats.calculatePercentile(50)); + assertEquals(0, stats.getMin()); + assertEquals(0, stats.getMax()); + assertEquals(0, stats.getSampleCount()); + } + + @Test + @DisplayName("Should reset all statistics correctly") + void testReset() { + stats.recordExecution(100, 50); + stats.recordExecution(200, 50); + stats.addChildTrackerId("child1"); + + stats.reset(); + + assertEquals(0, stats.getSampleCount()); + assertEquals(0, stats.getThresholdExceededCount()); + assertEquals(0, stats.getThresholdWithinCount()); + assertTrue(stats.getChildTrackerIds().isEmpty()); + } + + @Test + @DisplayName("Should track parent-child relationships") + void testParentChildRelationships() { + stats.setParentTrackerId("Parent.method"); + stats.addChildTrackerId("Child.method1"); + stats.addChildTrackerId("Child.method2"); + stats.addChildTrackerId("Child.method1"); // Duplicate, should not be added + + assertEquals("Parent.method", stats.getParentTrackerId()); + assertEquals(2, stats.getChildTrackerIds().size()); + assertTrue(stats.getChildTrackerIds().contains("Child.method1")); + assertTrue(stats.getChildTrackerIds().contains("Child.method2")); + } + + @Test + @DisplayName("Should throw exception for invalid percentile") + void testInvalidPercentile() { + stats.recordExecution(100, -1); + + assertThrows(IllegalArgumentException.class, () -> stats.calculatePercentile(-1)); + assertThrows(IllegalArgumentException.class, () -> stats.calculatePercentile(101)); + } +} diff --git a/src/test/java/io/github/thanglequoc/timerninja/StatisticsCollectorTest.java b/src/test/java/io/github/thanglequoc/timerninja/StatisticsCollectorTest.java new file mode 100644 index 0000000..ada445a --- /dev/null +++ b/src/test/java/io/github/thanglequoc/timerninja/StatisticsCollectorTest.java @@ -0,0 +1,123 @@ +package io.github.thanglequoc.timerninja; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for StatisticsCollector class. + */ +class StatisticsCollectorTest { + + private StatisticsCollector collector; + + @BeforeEach + void setUp() { + collector = StatisticsCollector.getInstance(); + collector.reset(); + } + + @Test + @DisplayName("Should record execution and create new statistics entry") + void testRecordExecution() { + collector.recordExecution("TestClass.method1", "TestClass", "method1()", 100, -1, null); + + assertTrue(collector.hasTracker("TestClass.method1")); + assertEquals(1, collector.getTrackedMethodCount()); + + MethodStatistics stats = collector.getStatistics("TestClass.method1"); + assertNotNull(stats); + assertEquals(1, stats.getSampleCount()); + assertEquals(100, stats.calculateAverage()); + } + + @Test + @DisplayName("Should aggregate multiple executions for same tracker") + void testMultipleExecutions() { + collector.recordExecution("TestClass.method1", "TestClass", "method1()", 100, -1, null); + collector.recordExecution("TestClass.method1", "TestClass", "method1()", 200, -1, null); + collector.recordExecution("TestClass.method1", "TestClass", "method1()", 300, -1, null); + + assertEquals(1, collector.getTrackedMethodCount()); + + MethodStatistics stats = collector.getStatistics("TestClass.method1"); + assertEquals(3, stats.getSampleCount()); + assertEquals(200, stats.calculateAverage()); + } + + @Test + @DisplayName("Should track multiple different methods") + void testMultipleMethods() { + collector.recordExecution("ClassA.method1", "ClassA", "method1()", 100, -1, null); + collector.recordExecution("ClassB.method2", "ClassB", "method2()", 200, -1, null); + collector.recordExecution("ClassC.method3", "ClassC", "method3()", 300, -1, null); + + assertEquals(3, collector.getTrackedMethodCount()); + assertTrue(collector.hasTracker("ClassA.method1")); + assertTrue(collector.hasTracker("ClassB.method2")); + assertTrue(collector.hasTracker("ClassC.method3")); + } + + @Test + @DisplayName("Should establish parent-child relationships") + void testParentChildRelationships() { + // Parent method first + collector.recordExecution("Parent.method", "Parent", "method()", 500, -1, null); + // Child method with parent reference + collector.recordExecution("Child.method", "Child", "method()", 100, -1, "Parent.method"); + + MethodStatistics childStats = collector.getStatistics("Child.method"); + assertEquals("Parent.method", childStats.getParentTrackerId()); + + MethodStatistics parentStats = collector.getStatistics("Parent.method"); + assertTrue(parentStats.getChildTrackerIds().contains("Child.method")); + } + + @Test + @DisplayName("Should reset all statistics") + void testReset() { + collector.recordExecution("Test.method1", "Test", "method1()", 100, -1, null); + collector.recordExecution("Test.method2", "Test", "method2()", 200, -1, null); + + assertEquals(2, collector.getTrackedMethodCount()); + + collector.reset(); + + assertEquals(0, collector.getTrackedMethodCount()); + assertFalse(collector.hasTracker("Test.method1")); + assertFalse(collector.hasTracker("Test.method2")); + } + + @Test + @DisplayName("Should get all statistics") + void testGetAllStatistics() { + collector.recordExecution("Test.method1", "Test", "method1()", 100, -1, null); + collector.recordExecution("Test.method2", "Test", "method2()", 200, -1, null); + + Collection allStats = collector.getAllStatistics(); + assertEquals(2, allStats.size()); + } + + @Test + @DisplayName("Should respect buffer size configuration") + void testBufferSizeConfiguration() { + collector.setMaxBufferSize(5); + assertEquals(5, collector.getMaxBufferSize()); + + assertThrows(IllegalArgumentException.class, () -> collector.setMaxBufferSize(0)); + assertThrows(IllegalArgumentException.class, () -> collector.setMaxBufferSize(-1)); + } + + @Test + @DisplayName("Should be singleton") + void testSingleton() { + StatisticsCollector instance1 = StatisticsCollector.getInstance(); + StatisticsCollector instance2 = StatisticsCollector.getInstance(); + + assertSame(instance1, instance2); + } +} diff --git a/src/test/java/io/github/thanglequoc/timerninja/StatisticsReportGeneratorTest.java b/src/test/java/io/github/thanglequoc/timerninja/StatisticsReportGeneratorTest.java new file mode 100644 index 0000000..e2c949c --- /dev/null +++ b/src/test/java/io/github/thanglequoc/timerninja/StatisticsReportGeneratorTest.java @@ -0,0 +1,161 @@ +package io.github.thanglequoc.timerninja; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for StatisticsReportGenerator class. + */ +class StatisticsReportGeneratorTest { + + private List stats; + + @BeforeEach + void setUp() { + stats = new ArrayList<>(); + + // Create sample statistics + MethodStatistics stat1 = new MethodStatistics("UserService.getUser", "UserService", "getUser()", 100); + stat1.recordExecution(50, 100); + stat1.recordExecution(150, 100); + stat1.recordExecution(100, 100); + stats.add(stat1); + + MethodStatistics stat2 = new MethodStatistics("OrderService.processOrder", "OrderService", "processOrder()", 100); + stat2.recordExecution(200, -1); + stat2.recordExecution(300, -1); + stats.add(stat2); + } + + @Test + @DisplayName("Should generate text report") + void testGenerateTextReport() { + String report = StatisticsReportGenerator.generateTextReport(stats); + + assertNotNull(report); + assertTrue(report.contains("Timer Ninja Statistics Report")); + assertTrue(report.contains("UserService.getUser")); + assertTrue(report.contains("OrderService.processOrder")); + assertTrue(report.contains("Generated:")); + } + + @Test + @DisplayName("Should generate JSON report") + void testGenerateJsonReport() { + String report = StatisticsReportGenerator.generateJsonReport(stats); + + assertNotNull(report); + assertTrue(report.contains("\"generatedAt\"")); + assertTrue(report.contains("\"totalMethods\": 2")); + assertTrue(report.contains("\"trackerId\": \"UserService.getUser\"")); + assertTrue(report.contains("\"trackerId\": \"OrderService.processOrder\"")); + assertTrue(report.contains("\"avgMs\"")); + assertTrue(report.contains("\"p50Ms\"")); + assertTrue(report.contains("\"p90Ms\"")); + } + + @Test + @DisplayName("Should generate HTML report with Bootstrap") + void testGenerateHtmlReport() { + String report = StatisticsReportGenerator.generateHtmlReport(stats); + + assertNotNull(report); + assertTrue(report.contains("")); + assertTrue(report.contains("bootstrap")); + assertTrue(report.contains("Timer Ninja Statistics Report")); + assertTrue(report.contains("UserService.getUser")); + assertTrue(report.contains(" 0); + assertTrue(jsonBytes.length > 0); + assertTrue(htmlBytes.length > 0); + + // Verify content + String textContent = new String(textBytes); + assertTrue(textContent.contains("Timer Ninja")); + } + + @Test + @DisplayName("Should handle empty statistics") + void testEmptyStatistics() { + List emptyStats = new ArrayList<>(); + + String textReport = StatisticsReportGenerator.generateTextReport(emptyStats); + assertTrue(textReport.contains("No statistics recorded")); + + String jsonReport = StatisticsReportGenerator.generateJsonReport(emptyStats); + assertTrue(jsonReport.contains("\"totalMethods\": 0")); + + String htmlReport = StatisticsReportGenerator.generateHtmlReport(emptyStats); + assertTrue(htmlReport.contains("No statistics recorded")); + } + + @Test + @DisplayName("Should use format parameter correctly") + void testFormatParameter() { + String text = StatisticsReportGenerator.generateReport(stats, "text"); + String json = StatisticsReportGenerator.generateReport(stats, "json"); + String html = StatisticsReportGenerator.generateReport(stats, "html"); + String defaultFormat = StatisticsReportGenerator.generateReport(stats, "unknown"); + + // Default falls back to text + assertTrue(defaultFormat.contains("Timer Ninja Statistics Report")); + assertFalse(defaultFormat.contains("")); + } + + @Test + @DisplayName("Should show threshold information in report") + void testThresholdInReport() { + String textReport = StatisticsReportGenerator.generateTextReport(stats); + String jsonReport = StatisticsReportGenerator.generateJsonReport(stats); + + // Text report should show threshold counts + assertTrue(textReport.contains("Threshold")); + + // JSON should include threshold fields + assertTrue(jsonReport.contains("\"thresholdMs\"")); + assertTrue(jsonReport.contains("\"thresholdExceeded\"")); + assertTrue(jsonReport.contains("\"thresholdWithin\"")); + } + + @Test + @DisplayName("Should escape special characters in JSON") + void testJsonEscaping() { + MethodStatistics specialStat = new MethodStatistics( + "Test.method\"with'special", + "Test", + "method\\path", + 100); + specialStat.recordExecution(100, -1); + + List specialStats = new ArrayList<>(); + specialStats.add(specialStat); + + String json = StatisticsReportGenerator.generateJsonReport(specialStats); + + // Should be valid JSON (special chars escaped) + assertTrue(json.contains("\\\"")); + assertTrue(json.contains("\\\\")); + } +}