From 5f234b1b3100324b4599481b7108e3b3c0ec3a94 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 10 Sep 2026 15:28:21 -0700 Subject: [PATCH 1/3] Switch changelog to per-PR fragment files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every open PR editing CHANGELOG.md's `## Pending` section directly meant any two concurrent PRs collided on the same lines, and each merge forced the next PR to redo the conflict resolution. Add changelog.d/, where each PR drops a new file instead of editing the shared section, so concurrent PRs can never conflict on this axis. scripts/assemble_changelog.py folds the fragments into a versioned CHANGELOG.md section at release time. See changelog.d/README.md for the fragment format and .claude/commands/release.md for the updated release process (not committed here — .claude/ isn't tracked in this repo yet). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- changelog.d/README.md | 40 ++++++++++++ scripts/assemble_changelog.py | 120 ++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 changelog.d/README.md create mode 100755 scripts/assemble_changelog.py diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000..777f32a --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,40 @@ +# Changelog fragments + +`CHANGELOG.md` used to grow a `## Pending` section that every in-flight PR +edited directly. That meant any two PRs open at the same time collided on +the same few lines, and every merge forced the next PR to re-resolve the +conflict — annoying on its own, and it got worse the more PRs were open at +once. + +Instead, each PR that needs a changelog entry adds **a new file** here +instead of editing `CHANGELOG.md`. Two PRs adding two different files can +never conflict with each other, no matter what order they merge in. + +## Adding an entry + +Create `changelog.d/.md` with the same shape a normal +changelog section would have — one or more `### Added` / `### Changed` / +`### Fixed` / `### Removed` headings, each followed by one or more bullet +points: + +```markdown +### Fixed + +- `scout widgets list` no longer crashes on an empty response (#42) +``` + +A single fragment can include more than one heading if a PR touches more +than one kind of change. Reference the issue or PR number in the bullet +text the same way existing changelog entries do. + +## Releasing + +At release time, `scripts/assemble_changelog.py ` reads every +fragment in this directory, merges same-type sections together (in +Added / Changed / Fixed / Removed order), writes the result into +`CHANGELOG.md` under a new `## [] - ` heading, and deletes +the fragment files it consumed. See `.claude/commands/release.md` for the +full release process. + +Do not edit `CHANGELOG.md`'s `## Pending`-equivalent state directly for an +in-progress change — add a fragment here instead. diff --git a/scripts/assemble_changelog.py b/scripts/assemble_changelog.py new file mode 100755 index 0000000..fbe9f99 --- /dev/null +++ b/scripts/assemble_changelog.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""assemble_changelog.py — fold changelog.d/ fragments into CHANGELOG.md. + +Reads every fragment in changelog.d/ (except README.md), merges their +### Added / ### Changed / ### Fixed / ### Removed sections (in that order, +de-duplicating identical bullets), writes the result into CHANGELOG.md as a +new versioned section right after the "# Changelog" heading, and deletes +the fragment files it consumed. + +Usage: + python3 scripts/assemble_changelog.py X.Y.Z [--date YYYY-MM-DD] + +Run this as part of cutting a release (see .claude/commands/release.md). +Review the resulting CHANGELOG.md diff before committing — this script +does not commit anything itself. +""" +import argparse +import datetime +import re +import sys +from pathlib import Path + +ORDER = ["Added", "Changed", "Fixed", "Removed"] +REPO_ROOT = Path(__file__).resolve().parent.parent +FRAGMENTS_DIR = REPO_ROOT / "changelog.d" +CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" + + +def parse_fragment(text): + """Return {heading: [bullet_lines]} from one fragment's text.""" + sections = {} + current = None + for line in text.splitlines(): + m = re.match(r"^### (\w+)", line) + if m: + current = m.group(1) + sections.setdefault(current, []) + continue + if current and line.strip().startswith("-"): + sections[current].append(line.rstrip()) + return sections + + +def render(sections): + out = [] + for heading in ORDER: + bullets = sections.get(heading) + if not bullets: + continue + out.append(f"### {heading}\n\n") + out.extend(b + "\n" for b in bullets) + out.append("\n") + # Preserve any non-standard heading at the end rather than silently + # dropping it. + for heading, bullets in sections.items(): + if heading not in ORDER and bullets: + out.append(f"### {heading}\n\n") + out.extend(b + "\n" for b in bullets) + out.append("\n") + return "".join(out) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Version being released, e.g. 0.5.0") + parser.add_argument( + "--date", + default=datetime.date.today().isoformat(), + help="Release date (default: today, YYYY-MM-DD)", + ) + args = parser.parse_args() + + fragment_paths = sorted( + p for p in FRAGMENTS_DIR.glob("*.md") if p.name != "README.md" + ) + if not fragment_paths: + print("No changelog fragments found in changelog.d/ — nothing to do.") + sys.exit(0) + + merged = {} + for path in fragment_paths: + sections = parse_fragment(path.read_text()) + if not sections: + print(f"warning: {path} has no ### heading / bullet content, skipping", file=sys.stderr) + continue + for heading, bullets in sections.items(): + existing = merged.setdefault(heading, []) + for b in bullets: + if b not in existing: + existing.append(b) + + if not merged: + print("Fragments found but none contained usable entries — nothing to do.") + sys.exit(0) + + body = render(merged) + new_section = f"## [{args.version}] - {args.date}\n\n{body}" + + changelog = CHANGELOG_PATH.read_text() + lines = changelog.splitlines(keepends=True) + # Insert right after the "# Changelog" heading (and the blank line after + # it, if present), before the first existing "## " section. + insert_at = 1 + while insert_at < len(lines) and not lines[insert_at].startswith("## "): + insert_at += 1 + new_changelog = "".join(lines[:insert_at]) + new_section + "".join(lines[insert_at:]) + CHANGELOG_PATH.write_text(new_changelog) + + for path in fragment_paths: + path.unlink() + + print(f"Wrote ## [{args.version}] - {args.date} to {CHANGELOG_PATH.relative_to(REPO_ROOT)}") + print(f"Consumed {len(fragment_paths)} fragment(s):") + for path in fragment_paths: + print(f" - {path.relative_to(REPO_ROOT)}") + print("\nReview the CHANGELOG.md diff, then commit both the changelog and the fragment deletions together.") + + +if __name__ == "__main__": + main() From 46fe61cb0d9a95dee5ea6302b025622d2978f9b9 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 10 Sep 2026 15:34:33 -0700 Subject: [PATCH 2/3] Fix span durations rendering 1000x too small in traces show The API populates a span's duration_ms and exclusive_duration_ms keys with values in seconds, not milliseconds. renderSpan formatted the raw value with FormatMs, so an 8-second span printed as "8ms", and the bar-fill ratio against the trace total was always ~0.001. Every bar collapsed to the 1-block minimum, the yellow/red color thresholds never fired, and the "slow" warning was unreachable. Rename the fields to DurationSeconds/ExclusiveDurationSeconds so the real unit is explicit, keeping the JSON tags on the API's wire names, and scale to milliseconds at the point of use. TOON output now shows the accurate field names; --json keys are unchanged. Closes #26 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- changelog.d/26.md | 3 ++ internal/api/types.go | 22 +++++++----- internal/output/format_mb_test.go | 2 +- internal/output/tree.go | 8 +++-- internal/output/tree_test.go | 59 +++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 changelog.d/26.md create mode 100644 internal/output/tree_test.go diff --git a/changelog.d/26.md b/changelog.d/26.md new file mode 100644 index 0000000..4e4ef16 --- /dev/null +++ b/changelog.d/26.md @@ -0,0 +1,3 @@ +### Fixed + +- `scout traces show` rendered every span's duration ~1000x too small, flattening the bar chart and making the color thresholds and `⚠ slow` warning unreachable. The API reports span durations in seconds despite the `duration_ms` key name; `--json` output is unaffected (#26) diff --git a/internal/api/types.go b/internal/api/types.go index b98f207..dee5d71 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -232,16 +232,20 @@ type TracesResult struct { } // TraceSpan represents a span in a trace tree. +// +// The API populates the duration_ms and exclusive_duration_ms keys with +// values in seconds, not milliseconds. The JSON tags keep the wire names so +// --json stays a faithful pass-through; the Go fields carry the real unit. type TraceSpan struct { - ID string `json:"id"` - ParentID *string `json:"parent_id"` - Operation string `json:"operation"` - Type string `json:"type"` - Description *string `json:"description"` - DurationMs float64 `json:"duration_ms"` - ExclusiveDurationMs float64 `json:"exclusive_duration_ms"` - Allocations int64 `json:"allocations"` - Children []TraceSpan `json:"children,omitempty"` + ID string `json:"id"` + ParentID *string `json:"parent_id"` + Operation string `json:"operation"` + Type string `json:"type"` + Description *string `json:"description"` + DurationSeconds float64 `json:"duration_ms"` + ExclusiveDurationSeconds float64 `json:"exclusive_duration_ms"` + Allocations int64 `json:"allocations"` + Children []TraceSpan `json:"children,omitempty"` } // TraceDetail contains full trace information including spans. diff --git a/internal/output/format_mb_test.go b/internal/output/format_mb_test.go index 908fff2..e868b2d 100644 --- a/internal/output/format_mb_test.go +++ b/internal/output/format_mb_test.go @@ -36,7 +36,7 @@ func TestRenderSpanTreeMemoryFooter(t *testing.T) { DurationInSeconds: 0.5, MemDelta: 2.9296875, Spans: []api.TraceSpan{ - {ID: "a", Operation: "Controller/users/index", DurationMs: 500}, + {ID: "a", Operation: "Controller/users/index", DurationSeconds: 0.5}, }, } out := RenderSpanTree(trace) diff --git a/internal/output/tree.go b/internal/output/tree.go index aeac902..4eeb611 100644 --- a/internal/output/tree.go +++ b/internal/output/tree.go @@ -70,14 +70,16 @@ func renderSpan(sb *strings.Builder, span api.TraceSpan, totalMs float64, prefix childPrefix = " " } + durationMs := span.DurationSeconds * 1000 + // Duration bar barWidth := 20 - ratio := span.DurationMs / totalMs + ratio := durationMs / totalMs if ratio > 1 { ratio = 1 } filled := int(ratio * float64(barWidth)) - if filled < 1 && span.DurationMs > 0 { + if filled < 1 && durationMs > 0 { filled = 1 } bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) @@ -106,7 +108,7 @@ func renderSpan(sb *strings.Builder, span api.TraceSpan, totalMs float64, prefix prefix, connector, opFormatted, - FormatMs(span.DurationMs), + FormatMs(durationMs), barColor.Render(bar), warning, ) diff --git a/internal/output/tree_test.go b/internal/output/tree_test.go new file mode 100644 index 0000000..af1fe13 --- /dev/null +++ b/internal/output/tree_test.go @@ -0,0 +1,59 @@ +package output + +import ( + "strings" + "testing" + + "github.com/scoutapm/scout/internal/api" + "github.com/stretchr/testify/assert" +) + +// spanLine returns the rendered line containing op, with ANSI styling stripped. +func spanLine(t *testing.T, out, op string) string { + t.Helper() + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, op) { + return line + } + } + t.Fatalf("no rendered line for span %q in:\n%s", op, out) + return "" +} + +func TestRenderSpanTreeSpanDurationMatchesTraceDuration(t *testing.T) { + trace := api.TraceDetail{ + ID: 3, + MetricName: "Controller/users/index", + DurationInSeconds: 7.9, + Spans: []api.TraceSpan{ + {ID: "a", Operation: "Middleware/Summary", DurationSeconds: 7.9}, + }, + } + out := RenderSpanTree(trace) + + assert.Contains(t, out, "Trace #3 — Controller/users/index — 7.9s") + assert.Contains(t, spanLine(t, out, "Middleware/Summary"), "7.9s") +} + +func TestRenderSpanTreeBarReflectsShareOfTrace(t *testing.T) { + trace := api.TraceDetail{ + ID: 4, + MetricName: "Controller/users/index", + DurationInSeconds: 10, + Spans: []api.TraceSpan{ + {ID: "big", Operation: "ActiveRecord/User/find", DurationSeconds: 6}, + {ID: "small", Operation: "View/users/_row", DurationSeconds: 0.5}, + }, + } + out := RenderSpanTree(trace) + + big := spanLine(t, out, "ActiveRecord/User/find") + assert.Contains(t, big, "⚠ slow") + assert.Equal(t, 12, strings.Count(big, "█")) + assert.Equal(t, 8, strings.Count(big, "░")) + + small := spanLine(t, out, "View/users/_row") + assert.NotContains(t, small, "⚠ slow") + assert.Equal(t, 1, strings.Count(small, "█")) + assert.Equal(t, 19, strings.Count(small, "░")) +} From d5e75ae51b09a6f679916d978d0aae4f07711137 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 11 Sep 2026 10:25:14 -0700 Subject: [PATCH 3/3] Revert to direct CHANGELOG.md edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop changelog.d/ and the assembly script — decided the fragment-file system was more process than this repo's PR concurrency warrants. Move this PR's changelog entry into CHANGELOG.md's Pending section directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- CHANGELOG.md | 6 ++ changelog.d/26.md | 3 - changelog.d/README.md | 40 ------------ scripts/assemble_changelog.py | 120 ---------------------------------- 4 files changed, 6 insertions(+), 163 deletions(-) delete mode 100644 changelog.d/26.md delete mode 100644 changelog.d/README.md delete mode 100755 scripts/assemble_changelog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bc03b..41d8b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Pending + +### Fixed + +- `scout traces show` rendered every span's duration ~1000x too small, flattening the bar chart and making the color thresholds and `⚠ slow` warning unreachable. The API reports span durations in seconds despite the `duration_ms` key name; `--json` output is unaffected (#26) + ## [0.5.0] - 2026-09-10 This release rounds out command coverage across the whole Scout API surface diff --git a/changelog.d/26.md b/changelog.d/26.md deleted file mode 100644 index 4e4ef16..0000000 --- a/changelog.d/26.md +++ /dev/null @@ -1,3 +0,0 @@ -### Fixed - -- `scout traces show` rendered every span's duration ~1000x too small, flattening the bar chart and making the color thresholds and `⚠ slow` warning unreachable. The API reports span durations in seconds despite the `duration_ms` key name; `--json` output is unaffected (#26) diff --git a/changelog.d/README.md b/changelog.d/README.md deleted file mode 100644 index 777f32a..0000000 --- a/changelog.d/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Changelog fragments - -`CHANGELOG.md` used to grow a `## Pending` section that every in-flight PR -edited directly. That meant any two PRs open at the same time collided on -the same few lines, and every merge forced the next PR to re-resolve the -conflict — annoying on its own, and it got worse the more PRs were open at -once. - -Instead, each PR that needs a changelog entry adds **a new file** here -instead of editing `CHANGELOG.md`. Two PRs adding two different files can -never conflict with each other, no matter what order they merge in. - -## Adding an entry - -Create `changelog.d/.md` with the same shape a normal -changelog section would have — one or more `### Added` / `### Changed` / -`### Fixed` / `### Removed` headings, each followed by one or more bullet -points: - -```markdown -### Fixed - -- `scout widgets list` no longer crashes on an empty response (#42) -``` - -A single fragment can include more than one heading if a PR touches more -than one kind of change. Reference the issue or PR number in the bullet -text the same way existing changelog entries do. - -## Releasing - -At release time, `scripts/assemble_changelog.py ` reads every -fragment in this directory, merges same-type sections together (in -Added / Changed / Fixed / Removed order), writes the result into -`CHANGELOG.md` under a new `## [] - ` heading, and deletes -the fragment files it consumed. See `.claude/commands/release.md` for the -full release process. - -Do not edit `CHANGELOG.md`'s `## Pending`-equivalent state directly for an -in-progress change — add a fragment here instead. diff --git a/scripts/assemble_changelog.py b/scripts/assemble_changelog.py deleted file mode 100755 index fbe9f99..0000000 --- a/scripts/assemble_changelog.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""assemble_changelog.py — fold changelog.d/ fragments into CHANGELOG.md. - -Reads every fragment in changelog.d/ (except README.md), merges their -### Added / ### Changed / ### Fixed / ### Removed sections (in that order, -de-duplicating identical bullets), writes the result into CHANGELOG.md as a -new versioned section right after the "# Changelog" heading, and deletes -the fragment files it consumed. - -Usage: - python3 scripts/assemble_changelog.py X.Y.Z [--date YYYY-MM-DD] - -Run this as part of cutting a release (see .claude/commands/release.md). -Review the resulting CHANGELOG.md diff before committing — this script -does not commit anything itself. -""" -import argparse -import datetime -import re -import sys -from pathlib import Path - -ORDER = ["Added", "Changed", "Fixed", "Removed"] -REPO_ROOT = Path(__file__).resolve().parent.parent -FRAGMENTS_DIR = REPO_ROOT / "changelog.d" -CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" - - -def parse_fragment(text): - """Return {heading: [bullet_lines]} from one fragment's text.""" - sections = {} - current = None - for line in text.splitlines(): - m = re.match(r"^### (\w+)", line) - if m: - current = m.group(1) - sections.setdefault(current, []) - continue - if current and line.strip().startswith("-"): - sections[current].append(line.rstrip()) - return sections - - -def render(sections): - out = [] - for heading in ORDER: - bullets = sections.get(heading) - if not bullets: - continue - out.append(f"### {heading}\n\n") - out.extend(b + "\n" for b in bullets) - out.append("\n") - # Preserve any non-standard heading at the end rather than silently - # dropping it. - for heading, bullets in sections.items(): - if heading not in ORDER and bullets: - out.append(f"### {heading}\n\n") - out.extend(b + "\n" for b in bullets) - out.append("\n") - return "".join(out) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("version", help="Version being released, e.g. 0.5.0") - parser.add_argument( - "--date", - default=datetime.date.today().isoformat(), - help="Release date (default: today, YYYY-MM-DD)", - ) - args = parser.parse_args() - - fragment_paths = sorted( - p for p in FRAGMENTS_DIR.glob("*.md") if p.name != "README.md" - ) - if not fragment_paths: - print("No changelog fragments found in changelog.d/ — nothing to do.") - sys.exit(0) - - merged = {} - for path in fragment_paths: - sections = parse_fragment(path.read_text()) - if not sections: - print(f"warning: {path} has no ### heading / bullet content, skipping", file=sys.stderr) - continue - for heading, bullets in sections.items(): - existing = merged.setdefault(heading, []) - for b in bullets: - if b not in existing: - existing.append(b) - - if not merged: - print("Fragments found but none contained usable entries — nothing to do.") - sys.exit(0) - - body = render(merged) - new_section = f"## [{args.version}] - {args.date}\n\n{body}" - - changelog = CHANGELOG_PATH.read_text() - lines = changelog.splitlines(keepends=True) - # Insert right after the "# Changelog" heading (and the blank line after - # it, if present), before the first existing "## " section. - insert_at = 1 - while insert_at < len(lines) and not lines[insert_at].startswith("## "): - insert_at += 1 - new_changelog = "".join(lines[:insert_at]) + new_section + "".join(lines[insert_at:]) - CHANGELOG_PATH.write_text(new_changelog) - - for path in fragment_paths: - path.unlink() - - print(f"Wrote ## [{args.version}] - {args.date} to {CHANGELOG_PATH.relative_to(REPO_ROOT)}") - print(f"Consumed {len(fragment_paths)} fragment(s):") - for path in fragment_paths: - print(f" - {path.relative_to(REPO_ROOT)}") - print("\nReview the CHANGELOG.md diff, then commit both the changelog and the fragment deletions together.") - - -if __name__ == "__main__": - main()