Skip to content

Commit af2da33

Browse files
committed
feat: harden Lambda snapshot capture and comparison
- Prevent accidental snapshot overwrites without --overwrite - Preserve usable stream pages after partial CloudWatch failures - Complete split invocation pages before stopping pagination - Reject unrelated JSON before running snapshot comparisons - Add duration and memory regression gates plus JSON output - Normalize volatile log values to reduce noisy diffs - Add version subcommand - Add selfupdate subcommand
1 parent 578c98b commit af2da33

14 files changed

Lines changed: 1761 additions & 107 deletions

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
github_token: ${{ secrets.GITHUB_TOKEN }}
2828
goos: ${{ matrix.goos }}
2929
goarch: ${{ matrix.goarch }}
30-
ldflags: -s -w
30+
ldflags: -s -w -X main.appVersion=${{ github.event.release.tag_name }}
3131
build_flags: -trimpath
3232
overwrite: true
3333
sha256sum: true

README.md

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Or you can install by building from source directly as follows. Go 1.25 or later
1212
go install github.com/naterator/lambda-deploy-log-compare@latest
1313
```
1414

15+
To check for a newer GitHub release and replace the current executable:
16+
17+
```bash
18+
lambda-deploy-log-compare selfupdate
19+
```
20+
1521
## Quick Start
1622

1723
```bash
@@ -57,12 +63,13 @@ lambda-deploy-log-compare capture --function <name>[,<name>,...] --label <label>
5763
| `--out` | `.` | Output directory for snapshot JSON files |
5864
| `--region` | `us-west-2` | AWS region |
5965
| `--profile` | | AWS CLI profile name |
66+
| `--overwrite` | `false` | Replace an existing snapshot file with the same function and label |
6067

6168
Log groups are derived as `/aws/lambda/<function-name>`.
6269

6370
Duplicate function names are rejected so one capture run cannot silently rescan the same Lambda and overwrite the same snapshot path.
6471

65-
Output files are named from sanitized `<function-name>_<label>.json` components in the output directory. Path separators and traversal-style input such as `..` are rewritten so snapshot writes stay inside the chosen output directory. Each function gets its own snapshot file, and the output directory is created automatically when a snapshot is written.
72+
Output files are named from sanitized `<function-name>_<label>.json` components in the output directory. Path separators and traversal-style input such as `..` are rewritten so snapshot writes stay inside the chosen output directory. Each function gets its own snapshot file, and the output directory is created automatically when a snapshot is written. Existing snapshot files are not replaced unless `--overwrite` is set.
6673

6774
The `--offset` flag lets you look further back in time. For example, `--offset 100 --count 20` skips the 100 most recent invocations and captures the 20 after that. This is useful for grabbing a historical baseline to compare against.
6875

@@ -82,17 +89,37 @@ lambda-deploy-log-compare compare --a <baseline.json> --b <new.json> [options]
8289
| `--b` | | Path to new snapshot JSON file (required) |
8390
| `--strict` | `false` | Fail if either snapshot is missing `function_name` / `log_group`, or if those fields disagree |
8491
| `--fail-on-regression` | `false` | Exit non-zero if the new snapshot has more errors or new error patterns |
92+
| `--max-duration-regression-pct` | `0` | Exit non-zero if new p90 duration exceeds baseline p90 by more than this percentage. `0` disables this gate. |
93+
| `--max-memory-regression-pct` | `0` | Exit non-zero if new max peak memory exceeds baseline max peak memory by more than this percentage. `0` disables this gate. |
94+
| `--json` | `false` | Print a machine-readable JSON comparison summary instead of the human report |
8595

8696
The comparison includes:
8797

8898
- **Error count** — warns if errors increased
8999
- **Error patterns** — new patterns that appeared, old patterns that disappeared
90-
- **Regression gate**`--fail-on-regression` returns a non-zero exit after printing the report if errors increased or new error patterns appeared
100+
- **Regression gates**`--fail-on-regression` returns a non-zero exit after printing the report if errors increased or new error patterns appeared; the duration and memory threshold flags also return non-zero when their configured gates are exceeded
91101
- **Duration stats** — min, avg, p50, p90, max (in milliseconds), ignoring malformed values with an explicit ignored count
92102
- **Memory usage** — min, avg, max peak memory (in MB), ignoring malformed values with an explicit ignored count
93-
- **Log pattern diff** — new and gone log line patterns (UUIDs normalized)
103+
- **Log pattern diff** — new and gone log line patterns, normalizing request-specific values such as UUIDs, request IDs, timestamps, ARNs, long numeric IDs, and durations
104+
- **JSON output**`--json` emits counts, pattern diffs, metric stats, warnings, and regression reasons for CI consumers
94105
- **Snapshot mismatch warnings** — warns when the two files appear to be from different Lambda functions or log groups; `--strict` also fails when snapshot identity fields are missing
95106

107+
### `selfupdate`
108+
109+
Checks the latest GitHub release, downloads the matching binary and `.sha256` checksum for the current OS/architecture, verifies the checksum, and replaces the running executable when a newer release is available.
110+
111+
```
112+
lambda-deploy-log-compare selfupdate
113+
```
114+
115+
### `version`
116+
117+
Prints the current build version and exits. Release builds can set this with `-ldflags "-X main.appVersion=v1.2.3"`.
118+
119+
```
120+
lambda-deploy-log-compare version
121+
```
122+
96123
## Snapshot Format
97124

98125
Each snapshot file contains one `Snapshot` object with metadata plus an `invocations` array.
@@ -104,6 +131,7 @@ Important field notes:
104131
- `max_memory_used_mb` stores Lambda `Max Memory Used`, which is the value used for memory comparison stats and `peak_mem` in compare output.
105132
- Invocations that never emit a `REPORT` line are still captured, with blank duration and memory fields, so crashes and truncated runs are not silently dropped.
106133
- Older snapshots that used `mem_used_mb` and `max_mem_mb` still load correctly.
134+
- Compare rejects files that have neither snapshot metadata nor invocation records, so unrelated JSON is not treated as an empty snapshot.
107135

108136
## AWS Authentication
109137

@@ -117,10 +145,10 @@ The IAM principal needs these permissions:
117145
## How It Works
118146

119147
1. **Stream discovery** — Fetches recent log streams ordered by last event time (most recent first) page by page until the requested `offset + count` is satisfied or the log group is exhausted.
120-
2. **Invocation parsing** — Reads each stream from newest events backward, tracks invocations from Lambda `START` and `REPORT` markers, and uses inline `RequestId` hints plus current stream context to associate log lines that land outside the normal `START -> logs -> REPORT` sequence. Extracts duration, billed duration, memory size, and max memory used from `REPORT` lines when present, while still preserving recent invocations that crashed or were truncated before `REPORT`. Each stream gets a 30-second timeout, and collection stops early once enough invocations are found.
148+
2. **Invocation parsing** — Reads each stream from newest events backward, tracks invocations from Lambda `START` and `REPORT` markers, and uses inline `RequestId` hints plus current stream context to associate log lines that land outside the normal `START -> logs -> REPORT` sequence. Extracts duration, billed duration, memory size, and max memory used from `REPORT` lines when present, while still preserving recent invocations that crashed or were truncated before `REPORT`. Each stream gets a 30-second timeout, collection stops early once enough newest invocations have their `START` boundary in the collected pages, and pages fetched before a later stream read failure are still used with a warning.
121149
3. **Error detection** — Flags explicit error-style log lines such as `error`, `panic`, `fatal`, `traceback`, `exception`, and Lambda runtime failure messages, while skipping common benign counter-style phrases like `error_count=0`.
122150
4. **Snapshot selection** — From all discovered invocations (sorted by time, most recent first), skips the first `offset` invocations, then takes the next `count`.
123-
5. **Pattern normalization** — For comparison, log lines are normalized by collapsing UUID-like hex strings (32+ chars) to `<UUID>` and truncating to 100 characters. This lets you compare structural patterns rather than exact values.
151+
5. **Pattern normalization** — For comparison, log lines are normalized by collapsing request-specific values such as UUID-like hex strings (32+ chars), Lambda request IDs, timestamps, ARNs, long numeric IDs, and durations before truncating to 100 characters. This lets you compare structural patterns rather than exact values.
124152

125153
## Project Structure
126154

TODO.md

Lines changed: 0 additions & 3 deletions
This file was deleted.

capture.go

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"os"
89
"path/filepath"
@@ -23,11 +24,19 @@ type LogsClient interface {
2324

2425
const logEventsPageLimit int32 = 10_000
2526

27+
type CaptureOptions struct {
28+
Overwrite bool
29+
}
30+
2631
func logGroupForFunction(name string) string {
2732
return "/aws/lambda/" + name
2833
}
2934

3035
func runCapture(client LogsClient, funcName, logGroup string, count, offset int, label, outDir string) error {
36+
return runCaptureWithOptions(client, funcName, logGroup, count, offset, label, outDir, CaptureOptions{})
37+
}
38+
39+
func runCaptureWithOptions(client LogsClient, funcName, logGroup string, count, offset int, label, outDir string, opts CaptureOptions) error {
3140
if count <= 0 {
3241
return fmt.Errorf("count must be greater than 0")
3342
}
@@ -74,8 +83,11 @@ func runCapture(client LogsClient, funcName, logGroup string, count, offset int,
7483
events, err := fetchLogEvents(evCtx, client, logGroup, *stream.LogStreamName, needed-len(allInvocations))
7584
evCancel()
7685
if err != nil {
77-
fmt.Fprintf(stderr, " Warning: failed to get events from stream %s: %v\n", *stream.LogStreamName, err)
78-
continue
86+
if len(events) == 0 {
87+
fmt.Fprintf(stderr, " Warning: failed to get events from stream %s: %v\n", *stream.LogStreamName, err)
88+
continue
89+
}
90+
fmt.Fprintf(stderr, " Warning: failed to get complete events from stream %s: %v; using %d event(s) fetched before the failure\n", *stream.LogStreamName, err, len(events))
7991
}
8092

8193
invocations := parseInvocations(events)
@@ -126,14 +138,35 @@ func runCapture(client LogsClient, funcName, logGroup string, count, offset int,
126138
if err := os.MkdirAll(outDir, 0755); err != nil {
127139
return fmt.Errorf("create output directory: %w", err)
128140
}
129-
if err := os.WriteFile(outPath, data, 0644); err != nil {
141+
if err := writeSnapshotOutputFile(outPath, data, opts.Overwrite); err != nil {
130142
return fmt.Errorf("write snapshot: %w", err)
131143
}
132144

133145
fmt.Fprintf(stdout, " Wrote snapshot to %s (%d invocations)\n", outPath, len(records))
134146
return nil
135147
}
136148

149+
func writeSnapshotOutputFile(path string, data []byte, overwrite bool) error {
150+
if overwrite {
151+
return os.WriteFile(path, data, 0644)
152+
}
153+
154+
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
155+
if err != nil {
156+
if errors.Is(err, os.ErrExist) {
157+
return fmt.Errorf("snapshot already exists at %s; use --overwrite to replace it", path)
158+
}
159+
return err
160+
}
161+
162+
_, writeErr := file.Write(data)
163+
closeErr := file.Close()
164+
if writeErr != nil {
165+
return writeErr
166+
}
167+
return closeErr
168+
}
169+
137170
func fetchLogStreamPage(ctx context.Context, client LogsClient, logGroup string, limit int32, nextToken *string) ([]types.LogStream, *string, error) {
138171
out, err := client.DescribeLogStreams(ctx, &cloudwatchlogs.DescribeLogStreamsInput{
139172
LogGroupName: &logGroup,
@@ -181,7 +214,7 @@ func fetchLogEvents(ctx context.Context, client LogsClient, logGroup, streamName
181214
eventPages = append(eventPages, pageEvents)
182215
tracker.addPage(pageEvents)
183216

184-
if tracker.count() >= neededInvocations {
217+
if tracker.hasCompleteNewest(neededInvocations) {
185218
break
186219
}
187220
}
@@ -208,11 +241,17 @@ func flattenLogEventPages(pages [][]types.OutputLogEvent) []types.OutputLogEvent
208241
}
209242

210243
type invocationCompletenessTracker struct {
211-
seen map[string]struct{}
244+
observations map[string]*invocationObservation
245+
}
246+
247+
type invocationObservation struct {
248+
requestID string
249+
firstSeen time.Time
250+
hasStart bool
212251
}
213252

214253
func newInvocationCompletenessTracker() *invocationCompletenessTracker {
215-
return &invocationCompletenessTracker{seen: make(map[string]struct{})}
254+
return &invocationCompletenessTracker{observations: make(map[string]*invocationObservation)}
216255
}
217256

218257
func (t *invocationCompletenessTracker) addPage(events []types.OutputLogEvent) {
@@ -223,38 +262,80 @@ func (t *invocationCompletenessTracker) addPage(events []types.OutputLogEvent) {
223262
continue
224263
}
225264
msg := strings.TrimSpace(*ev.Message)
265+
ts := time.UnixMilli(*ev.Timestamp)
226266

227267
switch {
228268
case strings.HasPrefix(msg, "START RequestId: "):
229269
currentReqID = extractRequestID(msg, "START RequestId: ")
230-
t.mark(currentReqID)
270+
t.markStart(currentReqID, ts)
231271

232272
case strings.HasPrefix(msg, "END RequestId: "):
233273
currentReqID = extractRequestID(msg, "END RequestId: ")
274+
t.observe(currentReqID, ts)
234275

235276
case strings.HasPrefix(msg, "REPORT RequestId: "):
236277
currentReqID = extractRequestID(msg, "REPORT RequestId: ")
237-
t.mark(currentReqID)
278+
t.observe(currentReqID, ts)
238279

239280
default:
240281
if reqID := extractInlineRequestID(msg); reqID != "" {
241-
t.mark(reqID)
282+
t.observe(reqID, ts)
242283
continue
243284
}
244-
t.mark(currentReqID)
285+
t.observe(currentReqID, ts)
245286
}
246287
}
247288
}
248289

249-
func (t *invocationCompletenessTracker) mark(reqID string) {
250-
if reqID == "" {
290+
func (t *invocationCompletenessTracker) markStart(reqID string, ts time.Time) {
291+
observation := t.observe(reqID, ts)
292+
if observation == nil {
251293
return
252294
}
253-
t.seen[reqID] = struct{}{}
295+
observation.hasStart = true
254296
}
255297

256-
func (t *invocationCompletenessTracker) count() int {
257-
return len(t.seen)
298+
func (t *invocationCompletenessTracker) observe(reqID string, ts time.Time) *invocationObservation {
299+
if reqID == "" {
300+
return nil
301+
}
302+
observation, ok := t.observations[reqID]
303+
if !ok {
304+
observation = &invocationObservation{requestID: reqID, firstSeen: ts}
305+
t.observations[reqID] = observation
306+
return observation
307+
}
308+
if observation.firstSeen.IsZero() || ts.Before(observation.firstSeen) {
309+
observation.firstSeen = ts
310+
}
311+
return observation
312+
}
313+
314+
func (t *invocationCompletenessTracker) hasCompleteNewest(needed int) bool {
315+
if needed <= 0 {
316+
needed = 1
317+
}
318+
if len(t.observations) < needed {
319+
return false
320+
}
321+
322+
observations := make([]*invocationObservation, 0, len(t.observations))
323+
for _, observation := range t.observations {
324+
observations = append(observations, observation)
325+
}
326+
sort.SliceStable(observations, func(i, j int) bool {
327+
if observations[i].firstSeen.Equal(observations[j].firstSeen) {
328+
return observations[i].requestID < observations[j].requestID
329+
}
330+
return observations[i].firstSeen.After(observations[j].firstSeen)
331+
})
332+
333+
for i := 0; i < needed; i++ {
334+
if !observations[i].hasStart {
335+
return false
336+
}
337+
}
338+
return true
258339
}
259340

260341
func reverseLogEvents(events []types.OutputLogEvent) {

0 commit comments

Comments
 (0)