Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
22ac28a
docs: add tile merge report design spec
shimoncohen Sep 15, 2026
7b01558
docs: mark report error-handling as open PR question; link MAPCO-11688
shimoncohen Sep 15, 2026
c043385
docs: add tile merge report implementation plan
shimoncohen Sep 15, 2026
5e2145a
docs: note existing TaskExecutorTest call sites to update in plan Task 6
shimoncohen Sep 15, 2026
123ccf8
feat: add MergeStats to describe merge tile provenance
shimoncohen Sep 15, 2026
8fc1789
feat: expose target/source provenance from MergeTiles via MergeStats
shimoncohen Sep 15, 2026
292f6d4
feat: add MergeReport accumulator with counts, percentages and added-…
shimoncohen Sep 15, 2026
c287eec
docs: plan Task 3 emits camelCase JSON to match spec artifact shape
shimoncohen Sep 15, 2026
3ac051d
fix: serialize MergeReport JSON as camelCase
shimoncohen Sep 15, 2026
4ff3b2f
feat: add ReportWriter with FS and S3 sinks for merge report artifact
shimoncohen Sep 15, 2026
7c3219b
feat: add ReportOutputPath to job AdditionalParams
shimoncohen Sep 15, 2026
daba0e7
feat: classify added/merged/replaced tiles and emit merge report in T…
shimoncohen Sep 15, 2026
c6aaecc
feat: pass job ReportOutputPath from TaskRunner to TaskExecutor
shimoncohen Sep 15, 2026
25b1c19
build: register ReportWriter and add REPORT sink config
shimoncohen Sep 15, 2026
f47ed93
fix: resolve S3 client lazily in ReportWriter and add skipped-path tests
shimoncohen Sep 15, 2026
fea939a
chore: drop superpowers workflow docs from PR
shimoncohen Sep 15, 2026
854c7c7
feat(logging): structured JSON logs with jobId/taskId scope for corre…
shimoncohen Sep 17, 2026
f76a040
refactor: encapsulate tile classification in MergeReport.RecordOutcome
shimoncohen Sep 15, 2026
c042d1f
feat(dashboard): add per-task log investigation dashboard
shimoncohen Sep 17, 2026
0fe75d0
Merge remote-tracking branch 'origin/feat/tile-merge-report' into MAP…
shimoncohen Oct 15, 2026
578c336
feat: per-task/per-job statistics from logs
shimoncohen Sep 17, 2026
f871805
feat(dashboard): metrics-first logs dashboard with clickable task dri…
shimoncohen Sep 17, 2026
85c5b79
fix(dashboard): extract json fields into table columns; add error cou…
shimoncohen Sep 17, 2026
e0078c2
fix(dashboard): move error panels to second row above logs
shimoncohen Sep 17, 2026
db56c88
fix(dashboard): per-task table to third row; clickable job/task in er…
shimoncohen Sep 17, 2026
1448bd7
fix(dashboard): show 'No data' on report tiles for tasks with no merg…
shimoncohen Sep 17, 2026
d521f16
fix(dashboard): job cell fills job only (clears task); task cell fill…
shimoncohen Sep 17, 2026
6658d0f
fix(dashboard): carry loki datasource + namespace through drill-down …
shimoncohen Sep 17, 2026
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
1,134 changes: 1,134 additions & 0 deletions Assets/Dashboards/MergerLogsDashboard.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions MergerLogic/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ public static IServiceCollection RegisterOpenTelemetry(this IServiceCollection c
options.AddProcessor(
new SimpleLogRecordExportProcessor(new OpenTelemetryFormattedConsoleExporter(new ConsoleExporterOptions()))); //lgtm [cs/local-not-disposed]
options.SetResourceBuilder(resourceBuilder);
// required for the exporter to emit BeginScope correlation fields (jobId/taskId)
options.IncludeScopes = true;
});
});
#endregion Logger
Expand Down
2 changes: 2 additions & 0 deletions MergerLogic/ImageProcessing/ITileMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ namespace MergerLogic.ImageProcessing
public interface ITileMerger
{
Tile? MergeTiles(List<CorrespondingTileBuilder> tiles, Coord targetCoords, TileFormatStrategy strategy, bool uploadOnly = false);

Tile? MergeTiles(List<CorrespondingTileBuilder> tiles, Coord targetCoords, TileFormatStrategy strategy, out MergeStats stats, bool uploadOnly = false);
}
}
18 changes: 18 additions & 0 deletions MergerLogic/ImageProcessing/MergeStats.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace MergerLogic.ImageProcessing
{
/// <summary>
/// Describes which inputs contributed to a merged tile, used to classify the
/// write as added / merged / replaced. TargetUsed is false in upload-only mode.
/// </summary>
public readonly struct MergeStats
{
public bool TargetUsed { get; }
public bool AnySourceUsed { get; }

public MergeStats(bool targetUsed, bool anySourceUsed)
{
this.TargetUsed = targetUsed;
this.AnySourceUsed = anySourceUsed;
}
}
}
33 changes: 31 additions & 2 deletions MergerLogic/ImageProcessing/TileMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ public TileMerger(ITileScaler tileScaler, ILogger<TileMerger> logger)

public Tile? MergeTiles(List<CorrespondingTileBuilder> tiles, Coord targetCoords, TileFormatStrategy strategy, bool uploadOnly = false)
{
return this.MergeTiles(tiles, targetCoords, strategy, out _, uploadOnly);
}

public Tile? MergeTiles(List<CorrespondingTileBuilder> tiles, Coord targetCoords, TileFormatStrategy strategy,
out MergeStats stats, bool uploadOnly = false)
{
bool targetUsed = false;
bool anySourceUsed = false;

if(uploadOnly) {
this._logger.LogDebug($"[{MethodBase.GetCurrentMethod()?.Name}] Configured to upload only mode");
// Ignore target if in upload only mode
Expand All @@ -30,18 +39,21 @@ public TileMerger(ITileScaler tileScaler, ILogger<TileMerger> logger)
this._logger.LogDebug($"[{MethodBase.GetCurrentMethod()?.Name}] Only one source was found, using raw image");
Tile? rawTile = tiles[0]();
rawTile?.ConvertToFormat(strategy.ApplyStrategy(rawTile.Format));
stats = new MergeStats(false, rawTile != null);
return rawTile;
}
}

var images = this.GetImageList(tiles, targetCoords, uploadOnly);
bool hasTarget = !uploadOnly && tiles.Count > 0;
var images = this.GetImageList(tiles, targetCoords, uploadOnly, hasTarget, out targetUsed, out anySourceUsed);
IMagickImage<byte> image;

switch (images.Count)
{
case 0:
// There are no images
this._logger.LogDebug($"[{MethodBase.GetCurrentMethod()?.Name}] No images where found return null");
stats = new MergeStats(targetUsed, anySourceUsed);
return null;
case 1:
image = images[0];
Expand Down Expand Up @@ -91,14 +103,18 @@ public TileMerger(ITileScaler tileScaler, ILogger<TileMerger> logger)
tile = new Tile(targetCoords, image);
}
tile.ConvertToFormat(strategy.ApplyStrategy(tile.Format));
stats = new MergeStats(targetUsed, anySourceUsed);
return tile;
}

private List<MagickImage> GetImageList(List<CorrespondingTileBuilder> tiles, Coord targetCoords, bool uploadOnly)
private List<MagickImage> GetImageList(List<CorrespondingTileBuilder> tiles, Coord targetCoords, bool uploadOnly,
bool hasTarget, out bool targetUsed, out bool anySourceUsed)
{
var images = new List<MagickImage>();
int i = tiles.Count - 1;
Tile? tile = null;
targetUsed = false;
anySourceUsed = false;

bool hasAlpha = false;
try
Expand All @@ -118,7 +134,20 @@ private List<MagickImage> GetImageList(List<CorrespondingTileBuilder> tiles, Coo
continue;
}

int before = images.Count;
this.AddTileToImageList(targetCoords, tile, images, out hasAlpha);
if (images.Count > before)
{
if (hasTarget && i == 0)
{
targetUsed = true;
}
else
{
anySourceUsed = true;
}
}

if (!hasAlpha)
{
return images;
Expand Down
42 changes: 39 additions & 3 deletions MergerLogic/Monitoring/OpenTelemetryFormattedConsoleExporter.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using OpenTelemetry;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
using System.Text.Json;

namespace MergerLogic.Monitoring
{
Expand Down Expand Up @@ -29,9 +30,44 @@
var resource = this.ParseResource();
var serviceName = this.GetResourceAttribute(resource, SERVICE_NAME_ATTRIBUTE, "unknown_service");
var serviceVersion = this.GetResourceAttribute(resource, SERVICE_VERSION_ATTRIBUTE, "unknown_version");
var exception = record.Exception != null ? $" [{record.Exception}]" : string.Empty;

return $"[{this.FormatTime(record.Timestamp)}] [{record.LogLevel}] [{serviceName}] [{serviceVersion}] [{record.CategoryName}] [{Environment.CurrentManagedThreadId}] {record.State}{exception}";
var entry = new Dictionary<string, object?>
{
["time"] = this.FormatTime(record.Timestamp),
["level"] = record.LogLevel.ToString(),
["service"] = serviceName,
["version"] = serviceVersion,
["category"] = record.CategoryName,
["thread"] = Environment.CurrentManagedThreadId,
["message"] = record.State?.ToString(),

Check warning on line 42 in MergerLogic/Monitoring/OpenTelemetryFormattedConsoleExporter.cs

View workflow job for this annotation

GitHub Actions / Run Tests (6.0.x)

'LogRecord.State' is obsolete: 'State cannot be accessed safely outside of an ILogger.Log call stack. Use Attributes instead to safely access the data attached to a LogRecord. State will be removed in a future version.'
};

if (record.Exception != null)
{
entry["exception"] = record.Exception.ToString();
}

this.AddScopes(record, entry);

return JsonSerializer.Serialize(entry);
}

// Flatten ILogger.BeginScope key/value pairs to top-level fields (e.g. jobId/taskId) so they
// are queryable in Loki. Empty unless IncludeScopes is enabled (see DI setup).
private void AddScopes(LogRecord record, Dictionary<string, object?> entry)
{
record.ForEachScope((scope, state) =>
{
foreach (var pair in scope)
{
if (pair.Key == "{OriginalFormat}")
{
continue;
}

state[pair.Key] = pair.Value;
}
}, entry);
}

private string FormatTime(DateTime time)
Expand All @@ -41,7 +77,7 @@

private Dictionary<string, object> ParseResource()
{
var attributes = this.ParentProvider.GetResource()?.Attributes;

Check warning on line 80 in MergerLogic/Monitoring/OpenTelemetryFormattedConsoleExporter.cs

View workflow job for this annotation

GitHub Actions / Run Tests (6.0.x)

Possible null reference argument for parameter 'baseProvider' in 'Resource ProviderExtensions.GetResource(BaseProvider baseProvider)'.
return attributes != null ? new Dictionary<string, object>(attributes) : new Dictionary<string, object>();
}

Expand Down
59 changes: 59 additions & 0 deletions MergerLogicUnitTests/ImageProcessing/TileMergerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,65 @@ public void MergeTiles(Tile[] tiles, Coord targetCoord, TileFormatStrategy strat
CollectionAssert.AreEqual(expectedTileBytes, result.GetImageBytes());
}

[TestMethod]
[TestCategory("MergeTiles")]
public void MergeTilesStatsBlendedTargetAndSource()
{
var targetCoord = new Coord(15, 0, 0);
// target (index 0) is transparent, source (last) is transparent -> both enter the stack
var tiles = new[]
{
new Tile(targetCoord, File.ReadAllBytes("2.png")),
new Tile(targetCoord, File.ReadAllBytes("1.png"))
};
var tileBuilders = tiles.Select<Tile, CorrespondingTileBuilder>(tile => () => tile).ToList();

var result = this._testTileMerger.MergeTiles(tileBuilders, targetCoord, new TileFormatStrategy(TileFormat.Png), out var stats);

Assert.IsNotNull(result);
Assert.IsTrue(stats.TargetUsed);
Assert.IsTrue(stats.AnySourceUsed);
}

[TestMethod]
[TestCategory("MergeTiles")]
public void MergeTilesStatsOpaqueSourceOverTarget()
{
var targetCoord = new Coord(15, 0, 0);
// opaque source (last) short-circuits before the target (index 0) is reached
var tiles = new[]
{
new Tile(targetCoord, File.ReadAllBytes("1.png")),
new Tile(targetCoord, File.ReadAllBytes("3.jpeg"))
};
var tileBuilders = tiles.Select<Tile, CorrespondingTileBuilder>(tile => () => tile).ToList();

var result = this._testTileMerger.MergeTiles(tileBuilders, targetCoord, new TileFormatStrategy(TileFormat.Jpeg), out var stats);

Assert.IsNotNull(result);
Assert.IsFalse(stats.TargetUsed);
Assert.IsTrue(stats.AnySourceUsed);
}

[TestMethod]
[TestCategory("MergeTiles")]
public void MergeTilesStatsUploadOnly()
{
var targetCoord = new Coord(15, 0, 0);
var tiles = new[]
{
new Tile(targetCoord, File.ReadAllBytes("2.png")),
new Tile(targetCoord, File.ReadAllBytes("1.png"))
};
var tileBuilders = tiles.Select<Tile, CorrespondingTileBuilder>(tile => () => tile).ToList();

var result = this._testTileMerger.MergeTiles(tileBuilders, targetCoord, new TileFormatStrategy(TileFormat.Jpeg), out var stats, uploadOnly: true);

Assert.IsNotNull(result);
Assert.IsFalse(stats.TargetUsed);
Assert.IsTrue(stats.AnySourceUsed);
}

#endregion
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using MergerLogic.Monitoring;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;

namespace MergerLogicUnitTests.Monitoring
{
[TestClass]
[TestCategory("unit")]
[TestCategory("monitoring")]
public class OpenTelemetryFormattedConsoleExporterTest
{
private TextWriter _originalOut = null!;

[TestInitialize]
public void BeforeEach()
{
this._originalOut = Console.Out;
}

[TestCleanup]
public void AfterEach()
{
Console.SetOut(this._originalOut);
}

private static ILoggerFactory BuildFactory()
{
return LoggerFactory.Create(builder =>
{
builder.ClearProviders();
builder.AddOpenTelemetry(options =>
{
options.IncludeScopes = true;
options.AddProcessor(new SimpleLogRecordExportProcessor(
new OpenTelemetryFormattedConsoleExporter(new ConsoleExporterOptions())));
});
});
}

private static JsonElement CaptureSingleLine(Action<ILogger> log)
{
var writer = new StringWriter();
Console.SetOut(writer);

using (var factory = BuildFactory())
{
log(factory.CreateLogger("TestCategory"));
}

string line = writer.ToString().Trim();
return JsonDocument.Parse(line).RootElement;
}

[TestMethod]
public void WhenLoggingInsideAScope_ShouldEmitScopeAsTopLevelJsonFields()
{
JsonElement entry = CaptureSingleLine(logger =>
{
using (logger.BeginScope(new Dictionary<string, object>
{
["jobId"] = "job-123",
["taskId"] = "task-456"
}))
{
logger.LogInformation("processing tiles");
}
});

Assert.AreEqual("processing tiles", entry.GetProperty("message").GetString());
Assert.AreEqual("job-123", entry.GetProperty("jobId").GetString());
Assert.AreEqual("task-456", entry.GetProperty("taskId").GetString());
Assert.AreEqual("Information", entry.GetProperty("level").GetString());
}

[TestMethod]
public void WhenLoggingWithoutAScope_ShouldNotEmitScopeFields()
{
JsonElement entry = CaptureSingleLine(logger => logger.LogInformation("no scope here"));

Assert.AreEqual("no scope here", entry.GetProperty("message").GetString());
Assert.IsFalse(entry.TryGetProperty("jobId", out _), "unexpected jobId field");
Assert.IsFalse(entry.TryGetProperty("taskId", out _), "unexpected taskId field");
}
}
}
4 changes: 3 additions & 1 deletion MergerService/Models/Jobs/JobParamersAdditiomalParams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ namespace MergerService.Models.Jobs
public class AdditionalParams
{
[JsonInclude] public string? JobTrackerServiceURL { get; }
[JsonInclude] public string? ReportOutputPath { get; }

[System.Text.Json.Serialization.JsonIgnore]
private JsonSerializerSettings _jsonSerializerSettings;

public AdditionalParams(string jobTrackerServiceURL)
public AdditionalParams(string jobTrackerServiceURL, string? reportOutputPath = null)
{
this.JobTrackerServiceURL = jobTrackerServiceURL;
this.ReportOutputPath = reportOutputPath;

this._jsonSerializerSettings = new JsonSerializerSettings();
this._jsonSerializerSettings.Converters.Add(new StringEnumConverter());
Expand Down
Loading
Loading