diff --git a/src/OtelImporter/Configuration/CommandLineOptions.cs b/src/OtelImporter/Configuration/CommandLineOptions.cs index db48a29..9059fd3 100644 --- a/src/OtelImporter/Configuration/CommandLineOptions.cs +++ b/src/OtelImporter/Configuration/CommandLineOptions.cs @@ -2,7 +2,7 @@ namespace OtelImporter.Configuration; internal sealed record CommandLineOptions { - public string? InputFile { get; init; } + public IReadOnlyList InputFiles { get; init; } = []; public string? Endpoint { get; init; } public OtlpProtocol? Protocol { get; init; } public double? MaxBatchesPerSecond { get; init; } @@ -26,7 +26,7 @@ internal sealed record CommandLineParseResult(CommandLineOptions? Options, strin // Hand-rolled argument parsing keeps the dependency surface (and AOT footprint) small. // Supported: -// positional, a *.jsonl/*.jsonl.zst trace file or a directory of them +// [...] positional, one or more trace files or directories // --endpoint, -e upstream OTLP endpoint (overrides environment variables) // --protocol, -p grpc | http (overrides port sniffing) // --max-rate, -r throttle: maximum batches per second @@ -44,7 +44,7 @@ internal static class CommandLineParser { public static CommandLineParseResult Parse(string[] args) { - string? inputFile = null; + var inputFiles = new List(); string? endpoint = null; OtlpProtocol? protocol = null; double? maxBatchesPerSecond = null; @@ -164,9 +164,7 @@ public static CommandLineParseResult Parse(string[] args) default: if (arg.StartsWith('-')) return CommandLineParseResult.Failure($"Unknown option '{arg}'."); - if (inputFile is not null) - return CommandLineParseResult.Failure($"Unexpected extra argument '{arg}'. Only one input file is supported."); - inputFile = arg; + inputFiles.Add(arg); break; } } @@ -179,7 +177,7 @@ public static CommandLineParseResult Parse(string[] args) return CommandLineParseResult.Success(new CommandLineOptions { - InputFile = inputFile, + InputFiles = inputFiles, Endpoint = endpoint, Protocol = protocol, MaxBatchesPerSecond = maxBatchesPerSecond, diff --git a/src/OtelImporter/Program.cs b/src/OtelImporter/Program.cs index acabc17..912ce5d 100644 --- a/src/OtelImporter/Program.cs +++ b/src/OtelImporter/Program.cs @@ -31,23 +31,27 @@ public static async Task RunAsync(string[] args) return ExitCode.Success; } - if (string.IsNullOrWhiteSpace(options.InputFile)) + if (options.InputFiles.Count == 0) { - Console.Error.WriteLine("error: no input file specified."); + Console.Error.WriteLine("error: no input specified."); Console.Error.WriteLine(); PrintUsage(Console.Error); return ExitCode.UsageError; } - // The positional argument may be a single file or a directory of trace files. - var resolution = InputResolver.Resolve(options.InputFile!); - if (resolution.Error is not null) + var allFiles = new List(); + foreach (var input in options.InputFiles) { - Console.Error.WriteLine($"error: {resolution.Error}"); - return ExitCode.UsageError; + var resolution = InputResolver.Resolve(input); + if (resolution.Error is not null) + { + Console.Error.WriteLine($"error: {resolution.Error}"); + return ExitCode.UsageError; + } + allFiles.AddRange(resolution.Files); } - var inputFiles = resolution.Files; + var inputFiles = allFiles.Distinct(StringComparer.Ordinal).ToList(); var filter = SpanTimeFilter.Create(options.From, options.To); // --inspect is a read-only pass: no export, so endpoint/protocol/rate/retry are all ignored. @@ -382,14 +386,14 @@ static void PrintUsage(TextWriter writer) writer.WriteLine("OtelImporter - stream OpenTelemetry trace files to an OTLP endpoint."); writer.WriteLine(); writer.WriteLine("Usage:"); - writer.WriteLine(" OtelImporter [--endpoint ] [--protocol ]"); + writer.WriteLine(" OtelImporter [...] [--endpoint ] [--protocol ]"); writer.WriteLine(" [--max-rate ] [--max-retries ] [--max-batch-size ]"); - writer.WriteLine(" OtelImporter --inspect"); + writer.WriteLine(" OtelImporter [...] --inspect"); writer.WriteLine(); writer.WriteLine("Arguments:"); - writer.WriteLine(" Path to a .jsonl or .jsonl.zst OTLP trace file, or a"); - writer.WriteLine(" directory; every .jsonl/.jsonl.zst file directly inside"); - writer.WriteLine(" it is processed in name order."); + writer.WriteLine(" [...] One or more paths to .jsonl/.jsonl.zst OTLP trace files,"); + writer.WriteLine(" or directories; every .jsonl/.jsonl.zst file directly"); + writer.WriteLine(" inside each directory is processed in name order."); writer.WriteLine(); writer.WriteLine("Options:"); writer.WriteLine(" -e, --endpoint Upstream OTLP endpoint. Overrides environment variables."); diff --git a/tests/OtelImporter.Tests/CommandLineParserTests.cs b/tests/OtelImporter.Tests/CommandLineParserTests.cs index 5a5063b..53f7904 100644 --- a/tests/OtelImporter.Tests/CommandLineParserTests.cs +++ b/tests/OtelImporter.Tests/CommandLineParserTests.cs @@ -10,7 +10,7 @@ public void ParsesPositionalInputFile() var result = CommandLineParser.Parse(["traces.jsonl"]); Assert.Null(result.Error); - Assert.Equal("traces.jsonl", result.Options!.InputFile); + Assert.Equal("traces.jsonl", result.Options!.InputFiles[0]); Assert.Null(result.Options.Endpoint); Assert.Null(result.Options.Protocol); } @@ -47,7 +47,7 @@ public void ParsesInspectFlag(string flag) Assert.Null(result.Error); Assert.True(result.Options!.Inspect); - Assert.Equal("traces.jsonl", result.Options.InputFile); + Assert.Equal("traces.jsonl", result.Options.InputFiles[0]); } [Fact] @@ -233,7 +233,7 @@ public void FlagsCanPrecedePositionalArgument() var result = CommandLineParser.Parse(["-e", "http://host:4317", "-p", "grpc", "traces.jsonl"]); Assert.Null(result.Error); - Assert.Equal("traces.jsonl", result.Options!.InputFile); + Assert.Equal("traces.jsonl", result.Options!.InputFiles[0]); Assert.Equal("http://host:4317", result.Options.Endpoint); Assert.Equal(OtlpProtocol.Grpc, result.Options.Protocol); } @@ -316,10 +316,11 @@ public void RejectsMissingFlagValue() } [Fact] - public void RejectsTwoPositionalArguments() + public void ParsesMultiplePositionalArguments() { - var result = CommandLineParser.Parse(["one.jsonl", "two.jsonl"]); + var result = CommandLineParser.Parse(["one.jsonl", "two.jsonl", "three/"]); - Assert.NotNull(result.Error); + Assert.Null(result.Error); + Assert.Equal(["one.jsonl", "two.jsonl", "three/"], result.Options!.InputFiles); } } diff --git a/tests/OtelImporter.Tests/InputResolverTests.cs b/tests/OtelImporter.Tests/InputResolverTests.cs index 724c70f..33f2168 100644 --- a/tests/OtelImporter.Tests/InputResolverTests.cs +++ b/tests/OtelImporter.Tests/InputResolverTests.cs @@ -100,6 +100,26 @@ public void FailsWhenDirectoryHasNoTraceFiles() Assert.Empty(result.Files); } + [Fact] + public void SameFilenameInDifferentDirectoriesAreNotDeduplicated() + { + // Dedup in Program.cs uses the full path, so dir1/file.jsonl and dir2/file.jsonl + // are distinct even though the filename is the same. + var dir1 = Directory.CreateDirectory(Path.Combine(_dir, "dir1")).FullName; + var dir2 = Directory.CreateDirectory(Path.Combine(_dir, "dir2")).FullName; + var file1 = Path.Combine(dir1, "traces.jsonl"); + var file2 = Path.Combine(dir2, "traces.jsonl"); + File.WriteAllText(file1, ""); + File.WriteAllText(file2, ""); + + var allFiles = new List(); + allFiles.AddRange(InputResolver.Resolve(dir1).Files); + allFiles.AddRange(InputResolver.Resolve(dir2).Files); + var deduped = allFiles.Distinct(StringComparer.Ordinal).ToList(); + + Assert.Equal([file1, file2], deduped); + } + [Fact] public void FailsWhenPathDoesNotExist() {