Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ file completion.
- Read commands accept `--profile` to append SQL timing, row-count, and
`EXPLAIN QUERY PLAN` JSON after the normal result; `--slow-query-ms <n>` logs
profiled SQL statements that meet the threshold.
- Read commands also accept `--trace=stderr|file|none` (default `none`) to emit
one structured JSON trace line with sanitized parameters, elapsed time,
result count when available, exit code, and error status. `file` writes daily
`query-trace-YYYYMMDD.jsonl` files next to the persistent lifecycle log.
- `cdidx diff <db1> <db2>` compares two index databases for CI or drift
debugging, reporting schema, file, symbol, and reference deltas with stable
exit codes: `0` identical, `1` drift, `2` schema mismatch, `3` unreadable DB.
Expand Down Expand Up @@ -340,6 +344,10 @@ POSIX 環境では、persistent global tool stderr log は開くたびに所有
- read 系コマンドは `--profile` で通常結果の後に SQL の時間、行数、
`EXPLAIN QUERY PLAN` の JSON を追加できます。`--slow-query-ms <n>` は
閾値以上の profiled SQL をログに記録します。
- read 系コマンドは `--trace=stderr|file|none`(既定は `none`)も受け付け、
sanitise 済みパラメータ、経過時間、取得可能な場合の result count、exit code、
error 状態を含む構造化 JSON trace を 1 行出力できます。`file` は persistent
lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書きます。
- `cdidx diff <db1> <db2>` は CI や drift 調査向けに 2 つの index DB を比較し、
schema、file、symbol、reference の差分を報告します。exit code は `0` identical、
`1` drift、`2` schema mismatch、`3` unreadable DB です。
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/unreleased/1901.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: added
issues:
- 1901
affected:
- src/CodeIndex/Cli/ProgramRunner.cs
- src/CodeIndex/Cli/CliFlagSchema.cs
- tests/CodeIndex.Tests/ProgramRunnerTests.cs
- README.md
---

## English

- **CLI query commands can emit opt-in structured traces (#1901)** — read-side commands now accept `--trace=stderr|file|none` to write one sanitized JSON trace line with elapsed time, result-count field, exit code, and error status for CI and pipeline diagnostics.

## 日本語

- **CLI query command が opt-in の構造化 trace を出力できるようになりました (#1901)** — read 系コマンドは `--trace=stderr|file|none` を受け付け、CI や pipeline 診断向けに sanitise 済み JSON trace を 1 行出力します。trace には経過時間、result count field、exit code、error 状態が含まれます。
2 changes: 2 additions & 0 deletions src/CodeIndex/Cli/CliFlagSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ internal static class CliFlagSchema
];

private static readonly string[] VerboseQueryCommands = ProfileCommands;
private static readonly string[] TraceCommands = ProfileCommands;

public static IReadOnlyList<CliFlag> All { get; } = BuildAll();

Expand All @@ -183,6 +184,7 @@ private static IReadOnlyList<CliFlag> BuildAll()
new() { Name = "--profile", Description = "Emit SQL timing and EXPLAIN QUERY PLAN profile JSON after the normal result", Commands = Set(ProfileCommands) },
new() { Name = "--verbose", Description = "Emit query debug diagnostics to stderr, or _debug JSON when combined with --json", Commands = Set(VerboseQueryCommands.Concat(new[] { "index" }).ToArray()) },
new() { Name = "--slow-query-ms", ValuePlaceholder = "<n>", Description = "Log profiled SQL statements at or above this millisecond threshold", Commands = Set(ProfileCommands) },
new() { Name = "--trace", ValuePlaceholder = "<none|stderr|file>", Description = "Emit one structured JSON query trace line to stderr or a daily log file", Commands = Set(TraceCommands) },
new() { Name = "--limit", ValuePlaceholder = "<n>", Description = "Max results", Commands = Set(LimitCapableCommands) },
new() { Name = "--top", ValuePlaceholder = "<n>", Description = "Max results", Commands = Set(LimitCapableCommands) },
new() { Name = "--lang", ValuePlaceholder = "<lang>", Description = "Filter by language", Commands = Set(LangCapableCommands) },
Expand Down
294 changes: 294 additions & 0 deletions src/CodeIndex/Cli/ProgramRunner.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using CodeIndex.Database;
using CodeIndex.Mcp;
Expand Down Expand Up @@ -178,9 +180,19 @@ internal static int Run(
int exitCode;
if (queryRunner is not null)
{
if (!TryConsumeQueryTraceFlag(ref subArgs, out var traceMode, out var traceError))
{
CommandErrorWriter.Write(StripErrorPrefix(traceError), "use one of `none`, `stderr`, or `file`.");
GlobalToolLog.Info($"command_complete exit_code={CommandExitCodes.InvalidArgument} command={commandName} trace_flag_invalid=true");
EmitCommandMetric(commandName, args, commandStartTimestamp, commandStopwatch, CommandExitCodes.InvalidArgument);
return CommandExitCodes.InvalidArgument;
}

using var traceCapture = QueryTraceOutputCapture.TryStart(traceMode, subArgs);
exitCode = JsonEnvelopeWrapper.ShouldWrap(commandName, subArgs)
? JsonEnvelopeWrapper.RunWrapped(commandName, subArgs, appVersion, jsonOptions, queryRunner)
: queryRunner(subArgs);
EmitQueryTrace(traceMode, commandName, subArgs, commandStartTimestamp, commandStopwatch, exitCode, traceCapture?.ResultCount);
}
else
{
Expand Down Expand Up @@ -529,6 +541,288 @@ internal static bool TryConsumeMetricsFlag(ref string[] args, out string? path,
return true;
}

internal static bool TryConsumeQueryTraceFlag(ref string[] args, out string traceMode, out string error)
{
traceMode = "none";
error = string.Empty;
if (args.Length == 0)
return true;

var kept = new List<string>(args.Length);
var passthrough = false;
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
if (passthrough)
{
kept.Add(arg);
continue;
}
if (arg == "--")
{
passthrough = true;
kept.Add(arg);
continue;
}

string? rawValue = null;
if (arg == "--trace")
{
if (i + 1 >= args.Length)
{
error = "Error: --trace requires a value (use `--trace stderr`, `--trace file`, `--trace none`, or `--trace=<mode>`).";
return false;
}
rawValue = args[++i];
}
else if (arg.StartsWith("--trace=", StringComparison.Ordinal))
{
rawValue = arg.Substring("--trace=".Length);
}
else
{
kept.Add(arg);
continue;
}

if (string.IsNullOrWhiteSpace(rawValue))
{
error = "Error: --trace requires a non-empty value.";
return false;
}
if (rawValue is not ("none" or "stderr" or "file"))
{
error = $"Error: --trace must be one of `none`, `stderr`, or `file`, got `{rawValue}`.";
return false;
}
traceMode = rawValue;
}

args = kept.ToArray();
return true;
}

private static void EmitQueryTrace(string mode, string commandName, string[] subArgs, DateTimeOffset startTimestamp, Stopwatch stopwatch, int exitCode, int? resultCount)
{
if (mode == "none")
return;

try
{
var elapsedMs = stopwatch.Elapsed.TotalMilliseconds;
var payload = BuildQueryTraceJson(commandName, subArgs, startTimestamp, elapsedMs, exitCode, resultCount);
if (mode == "stderr")
{
Console.Error.WriteLine(payload);
return;
}

var directory = GlobalToolLog.ResolveLogDirectoryForStatus();
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, $"query-trace-{DateTime.UtcNow:yyyyMMdd}.jsonl");
File.AppendAllText(path, payload + Environment.NewLine);
}
catch
{
// Best-effort only: trace output must never change query command behavior.
}
}

private static string BuildQueryTraceJson(string commandName, string[] subArgs, DateTimeOffset timestamp, double elapsedMs, int exitCode, int? resultCount)
{
var payload = new JsonObject
{
["timestamp"] = timestamp.ToString("O", CultureInfo.InvariantCulture),
["tool"] = commandName,
["source"] = "cli_query",
["parameters"] = BuildQueryTraceParameters(subArgs),
["elapsed_ms"] = Math.Round(elapsedMs, 3),
["result_count"] = resultCount,
["exit_code"] = exitCode,
};
if (exitCode != CommandExitCodes.Success)
payload["error"] = "command_failed";
return payload.ToJsonString(CreateDefaultJsonOptions());
}

private static JsonObject BuildQueryTraceParameters(string[] args)
{
var parameters = new JsonObject
{
["json"] = false,
["count"] = false,
};
var paths = new List<string>();
var excludePaths = new List<string>();
var passthrough = false;
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
if (passthrough)
continue;
if (arg == "--")
{
passthrough = true;
continue;
}

string? inlineValue = null;
var optionName = arg;
var equals = arg.IndexOf('=');
if (equals > 0)
{
optionName = arg[..equals];
inlineValue = arg[(equals + 1)..];
}

string? value = inlineValue;
if (value == null && optionName is "--lang" or "--limit" or "--top" or "--path" or "--exclude-path")
{
if (i + 1 < args.Length)
value = args[++i];
}

switch (optionName)
{
case "--json":
parameters["json"] = true;
if (!string.IsNullOrWhiteSpace(value))
parameters["json_format"] = value;
break;
case "--count":
parameters["count"] = true;
break;
case "--lang" when !string.IsNullOrWhiteSpace(value):
parameters["lang"] = value;
break;
case "--limit" when !string.IsNullOrWhiteSpace(value):
case "--top" when !string.IsNullOrWhiteSpace(value):
parameters["limit"] = value;
break;
case "--path" when !string.IsNullOrWhiteSpace(value):
paths.Add(value);
break;
case "--exclude-path" when !string.IsNullOrWhiteSpace(value):
excludePaths.Add(value);
break;
}
}
if (paths.Count > 0)
parameters["path"] = new JsonArray(paths.Select(path => JsonValue.Create(path)).ToArray());
if (excludePaths.Count > 0)
parameters["exclude_path"] = new JsonArray(excludePaths.Select(path => JsonValue.Create(path)).ToArray());
return parameters;
}

private sealed class QueryTraceOutputCapture : TextWriter
{
private readonly TextWriter _inner;
private readonly bool _countNumericOutput;
private readonly bool _countJsonLines;
private bool _disposed;

private QueryTraceOutputCapture(TextWriter inner, bool countNumericOutput, bool countJsonLines)
{
_inner = inner;
_countNumericOutput = countNumericOutput;
_countJsonLines = countJsonLines;
}

public override Encoding Encoding => _inner.Encoding;
public int? ResultCount { get; private set; }

public static QueryTraceOutputCapture? TryStart(string traceMode, string[] args)
{
if (traceMode == "none")
return null;

var capture = new QueryTraceOutputCapture(
Console.Out,
HasFlag(args, "--count"),
HasFlag(args, "--json") && !HasInlineValue(args, "--json", "array"));
Console.SetOut(capture);
return capture;
}

public override void Write(char value) => _inner.Write(value);
public override void Write(string? value) => _inner.Write(value);

public override void WriteLine(string? value)
{
_inner.WriteLine(value);
ObserveLine(value);
}

public override void WriteLine()
{
_inner.WriteLine();
ObserveLine(string.Empty);
}

protected override void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
Console.SetOut(_inner);
_disposed = true;
}
base.Dispose(disposing);
}

private void ObserveLine(string? value)
{
if (value == null)
return;

var trimmed = value.Trim();
if (_countNumericOutput && int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) && count >= 0)
{
ResultCount = count;
return;
}

if (_countJsonLines && trimmed.StartsWith('{'))
ResultCount = (ResultCount ?? 0) + 1;
}

private static bool HasFlag(string[] args, string name)
{
var passthrough = false;
foreach (var arg in args)
{
if (passthrough)
continue;
if (arg == "--")
{
passthrough = true;
continue;
}
if (arg == name || arg.StartsWith(name + "=", StringComparison.Ordinal))
return true;
}
return false;
}

private static bool HasInlineValue(string[] args, string name, string value)
{
var expected = name + "=" + value;
var passthrough = false;
foreach (var arg in args)
{
if (passthrough)
continue;
if (arg == "--")
{
passthrough = true;
continue;
}
if (arg == expected)
return true;
}
return false;
}
}

internal static void EmitCommandMetric(string tool, string[] args, DateTimeOffset startTimestamp, Stopwatch stopwatch, int exitCode, string? error = null)
{
if (!MetricsSink.IsActive)
Expand Down
Loading
Loading