DevOps Guard - DevLogs
Revisiting .NET and C#
Intro
DevOpsGuard is envisioned as a web API service that tracks work items (tickets), compute simple operational KPIs (backlog health, SLA breach rate, risk), ingest ops events (e.g. Build failed), and show everything in a lightweight dashboard.
I realized that my .NET and C# skills were developed during the second year of my degree, but haven’t really been used all that much since. Over the past four days, I took it upon myself to carefully build an application from the ground up and log everything. This will help me revisit this part of my skillset, rebuild it, and come out with a new project under my belt.
Goals
I had several constraints/goals I set out for this project:
- Modern Minimal API in .NET 8
- EF Core with SQL Server; code-first migrations
- Validation with consistent error handling
- OpenAPI with summaries and examples
- Background Job (daily) to snapshot metrics
- CSV export for history
- Docker Compose for API; GHCR image publishing
- A single-file dashboard (no Node) served from wwwroot/
Environment & Tooling
- Windows 10/11
- .NET SDK 8.0.20 (
dotnet --info) - Docker Desktop4.0 (
docker --version) - VS Code with C# Dev Kit + REST Client
- SQL Server (containerized via Compose)
Sanity Checks
docker run hello-world(success)dotnet new(works)- Browser opens
https://localhost<port>/swagger
These are some super simple validations to make sure everything is working before I kicked things off.
Project scaffolding (top level)
I created a multi-project solution consisting of domain, infra, API. Here’s what the root surface looks like:
src/
DevOpsGuard.Domain/ // entities, enums
DevOpsGuard.Infrastructure/ // EF Core DbContext, configurations, repositories, migrations
DevOpsGuard.Api/ // minimal API, endpoints, filters, wwwroot dashboard
Key design decisions:
- Minimal APIs for crisp endpoint definitions + typed results.
- Domain first: entity & enum definitions live under Domain.
- Infra holds EF Core + repository; API wires DI and endpoints.
Domain Model
Everything is centered on WorkItem and a MetricsSnapshot
Here’s a simplified Entities block:
// Work item + lifecycle
public enum Priority { Low, Medium, High, P0 }
public enum WorkItemStatus { Open, InProgress, Blocked, Resolved }
public sealed class WorkItem {
public Guid Id { get; }
public string Title { get; private set; }
public string Service { get; private set; }
public Priority Priority { get; private set; }
public DateOnly? DueDate { get; private set; }
public WorkItemStatus Status { get; private set; } = WorkItemStatus.Open;
public string? Component { get; private set; }
public string? Assignee { get; private set; }
public List<string> Labels { get; } = new();
public DateTime CreatedAtUtc { get; private set; }
public DateTime UpdatedAtUtc { get; private set; }
// domain methods: Rename, ChangePriority, SetStatus, etc.
}
public sealed class MetricsSnapshot {
public Guid Id { get; init; }
public DateTime CapturedAtUtc { get; init; }
public double BacklogHealthPct { get; init; }
public double SlaBreachRatePct { get; init; }
public int OverdueCount { get; init; }
public double RiskAvg { get; init; }
}
I’ve also defined DTOs for create/update/list/response shapes.
Infrastructure: EF Core & configuration
Db Context + mapping
- SQL Server provider
DateOnlymapped viaValueConverterList<string> Labelsflattened as comma-separated string with aValueConverter+ValueComparer(to get EF change tracking right)
On my way, I ran into a few compiler/EF expression tree issues:
- Using a statement-body lambda inside
ValueComparerhash calculation causes “A lambda expression with a statement body cannot be converted to an expression tree”
Fix: switch to expression-bodied lambdas:
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s?.GetHashCode() ?? 0))
-
Using
ispattern or local function in comparer “An expression tree may not contain…”. Fix: stick to simple null-coalesced sequences andSequenceEqual. -
Null warnings when comparing lists Fix: normalize with
a ?? Empty,b ?? EmptybeforeSequence Equal.
Migrations & schema
-
First run returned “Invalid object name ‘WorkItems’” when POSTing → classic sign the database/table wasn’t created.
Root cause: migrations not applied.
Fix path:-
Add EF Design package to startup (API) project (tools error demanded it).
-
dotnet ef migrations add Initial --project src/DevOpsGuard.Infrastructure -s src/DevOpsGuard.Api -
dotnet ef database update -s src/DevOpsGuard.Api -p src/DevOpsGuard.Infrastructure -
On startup,
db.Database.Migrate()whenUseSqlServer=true.
-
-
Later I added indexes (updated list sorting) with another migration; when EF said “name is used by an existing migration”, I picked a new unique name.
API: minimal endpoints & typed results
I implemented endpoints with typed results (Results<Created<WorkItemResponse>, BadRequest<string>>) and FluentValidation filters.
Endpoints:
-
POST /workitems -
GET /workitems/{id} -
GET /workitems(filters + paging + sorting) -
PATCH /workitems/{id} -
DELETE /workitems/{id} -
POST /dev/seed(dev seeding) -
POST /events/ingest(apply event rules) -
GET /metrics(live) -
GET /metrics/history&/metrics/history.csv(snapshots) -
POST /dev/metrics/snapshot(dev capture)
Gotchas I fixed
-
❌ Anonymous type vs
Ok<object>
ReturningTypedResults.Ok(new { ... })from a delegate typed asTask<Ok<object>>fails implicit conversion.Fix: make the delegate return
IResultor a matching anonymous result type, e.g.Task<Results<Ok<dynamic>, ProblemHttpResult>>or switch theOktoOk<object>(...). I standardized toIResultfor ad-hoc shapes. -
❌ Enum parsing in POST
When Swagger sent"priority": "High"but our model attempted numeric enum, System.Text.Json threw conversion errors.Fix: enable
JsonStringEnumConverterso I accept"High"/"P0"etc. -
OpenAPI polish
I added.WithOpenApi(op => { op.Summary = ...; op.RequestBody.Content["application/json"].Example = ...; })to document examples.Got “WithOpenApi does not exist” → missing
using Microsoft.AspNetCore.OpenApi;and ensuring Swashbuckle is added. After adding the using, it compiled and examples rendered.
Validation & consistent errors
-
FluentValidation validators for create/update DTOs.
-
A small endpoint filter (
ValidationFilter<T>) that runs validators and returns a ProblemDetails 400 with messages. -
I kept a couple of quick guard clauses for must-have fields (
Title,Service) in the handler too.
Metrics
KPIs:
-
Backlog Health % = % of open items touched in last 7 days
-
SLA Breach % = % of open items overdue
-
Risk Avg = base(priority) + 3 × daysOverdue, clamped to 0..100
I initially referenced WorkItemStatus.Done, then renamed the status to Resolved (more dev-friendly) and fixed queries accordingly.
History:
MetricsSnapshot stored daily.
-
/metricscalculates live (“now”) fromWorkItems. -
/metrics/historyreadsMetricsSnapshots. -
/metrics/history.csvstreams CSV (protected by API key). -
Background service runs daily at
Metrics__SnapshotHourLocalto persist one snapshot per day.
Chart empty?
The dashboard plot relies on snapshots, not the live endpoint. If you see an empty chart: call POST /dev/metrics/snapshot (or use the dashboard’s “Capture Snapshot” button) a couple times after making changes to items.
Events ingest
POST /events/ingest applies simple rule-based updates to a WorkItem:
-
build_failed→ addbuild-failed,InProgress, raise to ≥ High -
incident_opened→ addincident,Blocked, set toP0 -
deploy_succeeded→ adddeploy-ok -
coverage_dropped→ addqa, raise to ≥ Medium
I verified the flow by ingesting events and observing changes in GET /workitems/{id} and /metrics.
Dashboard (static; no Node)
I served a single HTML page from wwwroot/dashboard/index.html.
Two common pitfalls I hit and fixed:
- 404 for /dashboard → I hadn’t enabled
app.UseStaticFiles(). This was a rookie mistake. Fixes:-
add
app.UseStaticFiles(); -
add
app.MapGet("/dashboard", () => Results.Redirect("/dashboard/index.html")); -
ensure dashboard file is under
DevOpsGuard.Api/wwwroot/dashboard/index.htmland rebuild the Docker image.
-
Features:
-
API key input (
X-API-Keyheader for protected endpoints) -
Load KPIs + chart (
/metrics,/metrics/history?days=) -
CSV download button (fetch blob from
/metrics/history.csv) -
Work items list with filters + sorting + paging (client-side)
-
Delete action
-
Create work item form
-
Edit modal (PATCH selected fields)
-
Quick actions: Mark Resolved, Bump → P0
-
Optional auto-refresh every 60s
Everything is vanilla JS / Fetch API; no frameworks.
Docker Compose & environment
docker/docker-compose.yml orchestrates:
-
sqlserver:mcr.microsoft.com/mssql/server:2022-latest-
ACCEPT_EULA=Y,SA_PASSWORD=${SA_PASSWORD} -
Healthcheck using
sqlcmd -
Volume for data persistence
-
-
api: buildssrc/DevOpsGuard.Api/Dockerfile-
UseSqlServer=true -
ConnectionStrings__Default=Server=sqlserver,1433;... -
ApiKey=${API_KEY} -
ASPNETCORE_URLS=http://0.0.0.0:8080 -
Metrics auto-capture envs
-
Port
8080:8080
-
The big Compose pitfall:
Warnings showed:
“The
SA_PASSWORDvariable is not set. Defaulting to a blank string.”
Root cause: variable substitution happens before env_file is applied, and Compose looks for .env in the compose file’s directory. I had .env in repo root while compose lived in /docker.
Fixes (either works):
-
Pass
--env-file .\.envwhen running compose from repo root, or -
Move
.envtodocker/.env(I did this).
I also hit SQL login failed for ‘sa’ and container unhealthy when the SA password didn’t apply or when the data volume had an old password.
Fix: docker compose -f docker/docker-compose.yml down -v to drop volume, then up -d with correct env loaded.
Verification:
-
docker psshowsdevopsguard-sqlhealthy -
docker logs devopsguard-sqlshows “SQL Server is now ready…” -
Swagger at
http://localhost:8080/swaggerworks;/dev/seedcreates items.
API security
-
Simple API key header:
X-API-Key -
Applied to write operations (POST/PATCH/DELETE, dev endpoints, CSV).
-
Dashboard sends the key from the input box with every request that needs it.
(For production, use OAuth/OIDC; API key here is for demo simplicity.)
GHCR container publishing & repo hygiene
I enabled GitHub Container Registry publishing in CI and hit:
- ❌ invalid tag:
repository name must be lowercase
Fix: ensureghcr.io/<owner>/<repo>/devopsguard-api:...is all lowercase.
Security hygiene:
-
Never commit real secrets.
-
Use
docker/.env(git-ignored). -
If a secret ever leaks, rotate it and purge history if needed.
-
Enable GitHub secret scanning/push protection.
Testing & developer ergonomics
-
I kept in-memory repository support for integration tests (flip
UseSqlServer=false), then useWebApplicationFactory<Program>in a test project. -
For local dev without Docker, use
dotnet user-secretsforApiKeyand connection string to a local SQL instance.
Common errors I encountered (and how I recognized them)
-
500s on POST/GET after wiring EF → check logs:
Invalid object name 'WorkItems'⇒ migrations not applied. -
Enum JSON conversion errors on POST → accept strings via
JsonStringEnumConverter. -
Anonymous type typed result mismatch → change handler return type to
IResultor align genericOk<T>type. -
Expression trees complaining about
is/ statement-body lambdas inValueComparer→ use expression bodies and pure expressions only. -
Swagger / WithOpenApi missing → add
using Microsoft.AspNetCore.OpenApi;. -
/dashboard 404 → add
UseStaticFiles(), correct file path, rebuild image. -
SQL unhealthy + env warnings → move
.envnext to compose or provide--env-file, thendown -vandup -d.
Each time I hit an error, I:
-
Read the exact exception (top lines matter)
-
Mapped to the layer (routing, model binding, EF, DB, Docker)
-
Applied the minimal fix, rebuilt, re-tested
Architecture & data diagrams
High-level flow (Mermaid)
[Click here to view the chart on mermaid.js]
flowchart LR UI --|Auth: X-API-Key|--> API API --|Auth: X-API-Key|--> UI API -- EF Core --> DB[(SQL Server)] subgraph Background Svc[Hosted Service: Daily Snapshot] end API <-->|Auth: X-API-Key| UI Svc --> DB API -- CSV Export --> UI API -- OpenAPI --> UI
ERD
[Click here to view the diagram on mermaid.js]
erDiagram
WorkItem {
string Id PK
string Title
string Service
string Priority
date DueDate
string Status
string Component
string Assignee
string LabelsCsv
datetime CreatedAtUtc
datetime UpdatedAtUtc
}
MetricsSnapshot {
string Id PK
datetime CapturedAtUtc
float BacklogHealthPct
float SlaBreachRatePct
int OverdueCount
float RiskAvg
}
What each metric means
Backlog Health %
Interpretation: Team momentum on open work.
-
High (80–100%): most open items saw activity in the last week-good cadence.
-
Mid (40–80%): some items may be stagnating-investigate older items.
-
Low (<40%): backlog is stale-risk of hidden debt, re-prioritize or prune.
How to improve: make small updates, triage regularly, reassign or close stale items.
SLA Breach %
Interpretation: Timeliness adherence for open work.
-
Low (0–10%): most items on time-healthy.
-
Mid (10–30%): growing schedule pressure-review due dates and load.
-
High (>30%): frequent misses-adjust SLAs, unblock dependencies, add capacity.
How to improve: renegotiate due dates, clear blockers, escalate critical items.
Overdue Count
Interpretation: Absolute number of late items.
Use alongside SLA Breach %-a small team might have a low % but a non-trivial count; large teams the inverse.
How to improve: same as SLA Breach-reduce bottlenecks and recalibrate dates.
Risk (Average)
Interpretation: Aggregate operational risk from priority and lateness.
-
A High-priority item past due quickly pushes risk toward 100.
-
Items with no due date still contribute via their base priority.
Rule of thumb:
-
0–20: calm waters
-
20–50: watchlist-some high/late work exists
-
50–75: elevated-multiple late High/P0 items
-
75–100: red zone-urgent remediation needed
How to improve: resolve P0/High first, set and honor realistic due dates, break down large scope.
Demo run through
-
docker compose -f docker/docker-compose.yml up -d(withdocker/.envpresent) -
Browse
http://localhost:8080/swagger→ Authorize with your API key -
POST /dev/seed→ creates demo items -
GET /workitems→ see items; use filters & sorting -
POST /events/ingest→ e.g.,build_failedon an item -
GET /metrics→ observe KPI changes -
POST /dev/metrics/snapshot→ capture point(s) -
Dashboard
http://localhost:8080/dashboard→ paste API key, Load -
Try Download CSV, Edit, Delete, Mark Resolved, Bump → P0
-
Toggle auto-refresh; try presets
What I accomplished here
-
Clean .NET 8 minimal APIs, typed results, JSON enum strings, OpenAPI examples
-
EF Core with real converters/comparers and migrations
-
Concrete KPIs and a transparent risk function
-
Background processing that interacts with the DB correctly
-
A useful static dashboard (no infra tax) that demonstrates end-to-end UX
-
Docker Compose + healthchecks and secrets discipline
-
GHCR publishing and small but real CI/CD concerns