• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

moconnell / yolo / 28357222724

29 Jun 2026 07:57AM UTC coverage: 86.486% (+3.6%) from 82.93%
28357222724

Pull #131

github

web-flow
Merge 6d70d0c85 into 58d55311c
Pull Request #131: Feat: Telemetry

404 of 440 branches covered (91.82%)

Branch coverage included in aggregate %.

686 of 794 new or added lines in 12 files covered. (86.4%)

3 existing lines in 1 file now uncovered.

3423 of 3985 relevant lines covered (85.9%)

23.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.15
/src/YoloFunk/Functions/StorageQueryFunctions.cs
1
using System.Net;
2
using Azure;
3
using Azure.Data.Tables;
4
using Azure.Storage.Blobs;
5
using Microsoft.Azure.Functions.Worker;
6
using Microsoft.Azure.Functions.Worker.Http;
7
using Microsoft.Extensions.DependencyInjection;
8
using Microsoft.Extensions.Logging;
9
using YoloFunk.Dto;
10
using YoloFunk.Infrastructure;
11

12
namespace YoloFunk.Functions;
13

14
public sealed class StorageQueryFunctions(
3✔
15
    IServiceProvider serviceProvider,
3✔
16
    ILogger<StorageQueryFunctions> logger)
3✔
17
{
18
    private const string RebalanceEventsTableName = "rebalanceevents";
19
    private const string HttpRequestsTableName = "httprequestsindex";
20
    private const string HttpRequestsContainerName = "http-requests";
21

22
    [Function(nameof(GetRebalanceEvents))]
23
    public async Task<HttpResponseData> GetRebalanceEvents(
24
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "storage/rebalance-events")]
25
        HttpRequestData req,
26
        CancellationToken cancellationToken)
27
    {
1✔
28
        if (!TryGetTableServiceClient(req, out var tableServiceClient))
1✔
NEW
29
            return await ServiceUnavailableAsync(req, cancellationToken);
×
30

31
        var query = HttpQueryParameters.Parse(req.Url);
1✔
32
        var page = query.GetInt32("page", 1, 1, 10_000);
1✔
33
        var pageSize = query.GetInt32("pageSize", 100, 1, 500);
1✔
34
        var orderBy = NormalizeTableOrderBy(query.GetString("orderBy"));
1✔
35
        var direction = NormalizeTableDirection(query.GetString("direction"));
1✔
36
        var continuationToken = query.GetString("continuationToken");
1✔
37
        if (orderBy is null || direction is null)
1✔
NEW
38
            return await InvalidPagedTableSortAsync(req, cancellationToken);
×
39

40
        try
41
        {
1✔
42
            var pageResult = await QueryRebalanceEventsAsync(
1✔
43
                tableServiceClient,
1✔
44
                query,
1✔
45
                pageSize,
1✔
46
                continuationToken,
1✔
47
                cancellationToken);
1✔
48

49
            return await WritePagedResponseAsync(
1✔
50
                req,
1✔
51
                pageResult.Items,
1✔
52
                page,
1✔
53
                pageSize,
1✔
54
                orderBy,
1✔
55
                direction,
1✔
56
                cancellationToken,
1✔
57
                pageResult.NextContinuationToken,
1✔
58
                skipItems: false);
1✔
59
        }
NEW
60
        catch (RequestFailedException ex) when (ex.Status == 404)
×
NEW
61
        {
×
NEW
62
            return await WritePagedResponseAsync(
×
NEW
63
                req,
×
NEW
64
                Array.Empty<RebalanceEventQueryItem>(),
×
NEW
65
                page,
×
NEW
66
                pageSize,
×
NEW
67
                orderBy,
×
NEW
68
                direction,
×
NEW
69
                cancellationToken);
×
70
        }
NEW
71
        catch (Exception ex)
×
NEW
72
        {
×
NEW
73
            logger.LogError(ex, "Failed to query rebalance events");
×
NEW
74
            return await ErrorAsync(req, "Failed to query rebalance events", cancellationToken);
×
75
        }
76
    }
1✔
77

78
    [Function(nameof(GetHttpRequestCaptures))]
79
    public async Task<HttpResponseData> GetHttpRequestCaptures(
80
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "storage/http-requests")]
81
        HttpRequestData req,
82
        CancellationToken cancellationToken)
83
    {
1✔
84
        if (!TryGetTableServiceClient(req, out var tableServiceClient))
1✔
NEW
85
            return await ServiceUnavailableAsync(req, cancellationToken);
×
86

87
        var query = HttpQueryParameters.Parse(req.Url);
1✔
88
        var page = query.GetInt32("page", 1, 1, 10_000);
1✔
89
        var pageSize = query.GetInt32("pageSize", 100, 1, 500);
1✔
90
        var orderBy = NormalizeTableOrderBy(query.GetString("orderBy"));
1✔
91
        var direction = NormalizeTableDirection(query.GetString("direction"));
1✔
92
        var continuationToken = query.GetString("continuationToken");
1✔
93
        if (orderBy is null || direction is null)
1✔
NEW
94
            return await InvalidPagedTableSortAsync(req, cancellationToken);
×
95

96
        try
97
        {
1✔
98
            var pageResult = await QueryHttpRequestCapturesAsync(
1✔
99
                tableServiceClient,
1✔
100
                query,
1✔
101
                pageSize,
1✔
102
                continuationToken,
1✔
103
                cancellationToken);
1✔
104

105
            return await WritePagedResponseAsync(
1✔
106
                req,
1✔
107
                pageResult.Items,
1✔
108
                page,
1✔
109
                pageSize,
1✔
110
                orderBy,
1✔
111
                direction,
1✔
112
                cancellationToken,
1✔
113
                pageResult.NextContinuationToken,
1✔
114
                skipItems: false);
1✔
115
        }
NEW
116
        catch (RequestFailedException ex) when (ex.Status == 404)
×
NEW
117
        {
×
NEW
118
            return await WritePagedResponseAsync(
×
NEW
119
                req,
×
NEW
120
                Array.Empty<HttpRequestCaptureQueryItem>(),
×
NEW
121
                page,
×
NEW
122
                pageSize,
×
NEW
123
                orderBy,
×
NEW
124
                direction,
×
NEW
125
                cancellationToken);
×
126
        }
NEW
127
        catch (Exception ex)
×
NEW
128
        {
×
NEW
129
            logger.LogError(ex, "Failed to query HTTP request captures");
×
NEW
130
            return await ErrorAsync(req, "Failed to query HTTP request captures", cancellationToken);
×
131
        }
132
    }
1✔
133

134
    [Function(nameof(GetHttpRequestPayload))]
135
    public async Task<HttpResponseData> GetHttpRequestPayload(
136
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "storage/http-requests/payload")]
137
        HttpRequestData req,
138
        CancellationToken cancellationToken)
139
    {
1✔
140
        if (!TryGetBlobServiceClient(req, out var blobServiceClient))
1✔
NEW
141
            return await ServiceUnavailableAsync(req, cancellationToken);
×
142

143
        var blobName = HttpQueryParameters.Parse(req.Url).GetString("blobName");
1✔
144
        if (string.IsNullOrWhiteSpace(blobName))
1✔
145
        {
1✔
146
            var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
1✔
147
            await badRequest.WriteAsJsonAsync(
1✔
148
                new { Error = "Missing required query parameter: blobName" },
1✔
149
                cancellationToken);
1✔
150
            return badRequest;
1✔
151
        }
152

153
        try
NEW
154
        {
×
NEW
155
            var blobClient = blobServiceClient
×
NEW
156
                .GetBlobContainerClient(HttpRequestsContainerName)
×
NEW
157
                .GetBlobClient(blobName);
×
NEW
158
            var download = await blobClient.DownloadContentAsync(cancellationToken);
×
159

NEW
160
            var response = req.CreateResponse(HttpStatusCode.OK);
×
NEW
161
            response.Headers.Add("Content-Type", "application/json");
×
NEW
162
            await response.WriteStringAsync(download.Value.Content.ToString(), cancellationToken);
×
NEW
163
            return response;
×
164
        }
NEW
165
        catch (RequestFailedException ex) when (ex.Status == 404)
×
NEW
166
        {
×
NEW
167
            var notFound = req.CreateResponse(HttpStatusCode.NotFound);
×
NEW
168
            await notFound.WriteAsJsonAsync(
×
NEW
169
                new { Error = "HTTP request payload not found", BlobName = blobName },
×
NEW
170
                cancellationToken);
×
NEW
171
            return notFound;
×
172
        }
NEW
173
        catch (Exception ex)
×
NEW
174
        {
×
NEW
175
            logger.LogError(ex, "Failed to get HTTP request payload {BlobName}", blobName);
×
NEW
176
            return await ErrorAsync(req, "Failed to get HTTP request payload", cancellationToken);
×
177
        }
178
    }
1✔
179

180
    private bool TryGetTableServiceClient(
181
        HttpRequestData req,
182
        out TableServiceClient tableServiceClient)
183
    {
2✔
184
        tableServiceClient = serviceProvider.GetService<TableServiceClient>() ??
2✔
185
                             req.FunctionContext.InstanceServices.GetService<TableServiceClient>()!;
2✔
186

187
        return tableServiceClient is not null;
2✔
188
    }
2✔
189

190
    private bool TryGetBlobServiceClient(
191
        HttpRequestData req,
192
        out BlobServiceClient blobServiceClient)
193
    {
1✔
194
        blobServiceClient = serviceProvider.GetService<BlobServiceClient>() ??
1✔
195
                            req.FunctionContext.InstanceServices.GetService<BlobServiceClient>()!;
1✔
196

197
        return blobServiceClient is not null;
1✔
198
    }
1✔
199

200
    private static async Task<QueryPageResult<HttpRequestCaptureQueryItem>> QueryHttpRequestCapturesAsync(
201
        TableServiceClient tableServiceClient,
202
        HttpQueryParameters query,
203
        int pageSize,
204
        string? continuationToken,
205
        CancellationToken cancellationToken)
206
    {
1✔
207
        var host = query.GetString("host");
1✔
208
        var endpoint = query.GetString("endpoint");
1✔
209
        var method = query.GetString("method");
1✔
210
        var statusCode = query.GetString("statusCode");
1✔
211
        var contentHash = query.GetString("contentHash");
1✔
212
        var from = query.GetDateTimeOffset("from");
1✔
213
        var to = query.GetDateTimeOffset("to");
1✔
214

215
        var tableClient = tableServiceClient.GetTableClient(HttpRequestsTableName);
1✔
216
        var page = await QueryEntitiesPageAsync(
1✔
217
            tableClient,
1✔
218
            BuildPartitionFilter(host),
1✔
219
            pageSize,
1✔
220
            continuationToken,
1✔
221
            cancellationToken);
1✔
222

223
        var items = page.Items
1✔
224
            .Select(ToHttpRequestCaptureQueryItem)
1✔
225
            .Where(item => Matches(item.Host, host))
1✔
226
            .Where(item => Contains(item.Endpoint, endpoint))
1✔
227
            .Where(item => Matches(item.Method, method))
1✔
228
            .Where(item => !int.TryParse(statusCode, out var expectedStatusCode) || item.StatusCode == expectedStatusCode)
1✔
229
            .Where(item => Matches(item.ContentHash, contentHash))
1✔
230
            .Where(item => !from.HasValue || item.RequestTimeUtc >= from.Value)
1✔
231
            .Where(item => !to.HasValue || item.RequestTimeUtc <= to.Value)
1✔
232
            .ToArray();
1✔
233

234
        return new QueryPageResult<HttpRequestCaptureQueryItem>(items, page.NextContinuationToken);
1✔
235
    }
1✔
236

237
    private static async Task<QueryPageResult<RebalanceEventQueryItem>> QueryRebalanceEventsAsync(
238
        TableServiceClient tableServiceClient,
239
        HttpQueryParameters query,
240
        int pageSize,
241
        string? continuationToken,
242
        CancellationToken cancellationToken)
243
    {
1✔
244
        var strategy = query.GetString("strategy");
1✔
245
        var runId = query.GetString("runId");
1✔
246
        var eventType = query.GetString("eventType");
1✔
247
        var level = query.GetString("level");
1✔
248
        var coin = query.GetString("coin");
1✔
249
        var clientOrderId = query.GetString("clientOrderId");
1✔
250
        var from = query.GetDateTimeOffset("from");
1✔
251
        var to = query.GetDateTimeOffset("to");
1✔
252

253
        var tableClient = tableServiceClient.GetTableClient(RebalanceEventsTableName);
1✔
254
        var partitionFilter = !string.IsNullOrWhiteSpace(strategy) && !string.IsNullOrWhiteSpace(runId)
1✔
255
            ? BuildPartitionFilter($"{strategy}|{runId}")
1✔
256
            : null;
1✔
257
        var page = await QueryEntitiesPageAsync(
1✔
258
            tableClient,
1✔
259
            partitionFilter,
1✔
260
            pageSize,
1✔
261
            continuationToken,
1✔
262
            cancellationToken);
1✔
263

264
        var items = page.Items
1✔
265
            .Select(ToRebalanceEventQueryItem)
1✔
266
            .Where(item => Matches(item.StrategyName, strategy))
2✔
267
            .Where(item => Matches(item.RunId, runId))
2✔
268
            .Where(item => Matches(item.EventType, eventType))
2✔
269
            .Where(item => Matches(item.Level, level))
1✔
270
            .Where(item => Matches(item.Coin, coin))
1✔
271
            .Where(item => Matches(item.ClientOrderId, clientOrderId))
1✔
272
            .Where(item => !from.HasValue || item.TimestampUtc >= from.Value)
1✔
273
            .Where(item => !to.HasValue || item.TimestampUtc <= to.Value)
1✔
274
            .ToArray();
1✔
275

276
        return new QueryPageResult<RebalanceEventQueryItem>(items, page.NextContinuationToken);
1✔
277
    }
1✔
278

279
    private static async Task<QueryPageResult<TableEntity>> QueryEntitiesPageAsync(
280
        TableClient tableClient,
281
        string? filter,
282
        int pageSize,
283
        string? continuationToken,
284
        CancellationToken cancellationToken)
285
    {
2✔
286
        await foreach (var page in tableClient
8✔
287
                           .QueryAsync<TableEntity>(
2✔
288
                               filter,
2✔
289
                               maxPerPage: pageSize,
2✔
290
                               cancellationToken: cancellationToken)
2✔
291
                           .AsPages(continuationToken, pageSize))
2✔
292
        {
2✔
293
            return new QueryPageResult<TableEntity>([.. page.Values], page.ContinuationToken);
2✔
294
        }
295

NEW
296
        return new QueryPageResult<TableEntity>([], null);
×
297
    }
2✔
298

299
    private static string? BuildPartitionFilter(string? partitionKey)
300
    {
2✔
301
        return string.IsNullOrWhiteSpace(partitionKey)
2✔
302
            ? null
2✔
303
            : $"PartitionKey eq '{SanitizeTableKey(partitionKey).Replace("'", "''")}'";
2✔
304
    }
2✔
305

306
    private static async Task<HttpResponseData> WritePagedResponseAsync<T>(
307
        HttpRequestData req,
308
        IReadOnlyList<T> items,
309
        int page,
310
        int pageSize,
311
        string? orderBy,
312
        string direction,
313
        CancellationToken cancellationToken,
314
        string? nextContinuationToken = null,
315
        bool skipItems = true)
316
    {
2✔
317
        var response = req.CreateResponse(HttpStatusCode.OK);
2✔
318
        var pageItems = skipItems
2✔
319
            ? items
2✔
320
                .Skip((page - 1) * pageSize)
2✔
321
                .Take(pageSize)
2✔
322
                .ToArray()
2✔
323
            : [.. items.Take(pageSize)];
2✔
324

325
        await response.WriteAsJsonAsync(
2✔
326
            new PagedQueryResponse<T>(pageItems, page, pageSize, items.Count, orderBy, direction, nextContinuationToken),
2✔
327
            cancellationToken);
2✔
328
        return response;
2✔
329
    }
2✔
330

331
    private static HttpRequestCaptureQueryItem ToHttpRequestCaptureQueryItem(TableEntity entity)
332
    {
1✔
333
        return new HttpRequestCaptureQueryItem(
1✔
334
            GetString(entity, "Host"),
1✔
335
            GetString(entity, "Endpoint"),
1✔
336
            GetString(entity, "Url"),
1✔
337
            GetString(entity, "Method"),
1✔
338
            GetInt32(entity, "StatusCode"),
1✔
339
            GetString(entity, "BlobContainer"),
1✔
340
            GetString(entity, "BlobName"),
1✔
341
            GetString(entity, "ContentHash"),
1✔
342
            GetDateTimeOffset(entity, "RequestTimeUtc"),
1✔
343
            GetString(entity, "QueryParametersJson"));
1✔
344
    }
1✔
345

346
    private static RebalanceEventQueryItem ToRebalanceEventQueryItem(TableEntity entity)
347
    {
2✔
348
        return new RebalanceEventQueryItem(
2✔
349
            GetString(entity, "RunId"),
2✔
350
            GetString(entity, "StrategyName"),
2✔
351
            GetDateTimeOffset(entity, "TimestampUtc"),
2✔
352
            GetInt32(entity, "Sequence"),
2✔
353
            GetString(entity, "EventType"),
2✔
354
            GetString(entity, "Level"),
2✔
355
            GetString(entity, "Summary"),
2✔
356
            GetNullableString(entity, "WalletAddress"),
2✔
357
            GetNullableString(entity, "VaultAddress"),
2✔
358
            GetNullableString(entity, "Coin"),
2✔
359
            GetNullableString(entity, "ClientOrderId"),
2✔
360
            GetNullableString(entity, "OrderId"),
2✔
361
            GetString(entity, "PayloadJson"));
2✔
362
    }
2✔
363

364
    private static bool Matches(string? actual, string? expected) =>
365
        string.IsNullOrWhiteSpace(expected) ||
12✔
366
        string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
12✔
367

368
    private static bool Contains(string actual, string? expected) =>
369
        string.IsNullOrWhiteSpace(expected) ||
1✔
370
        actual.Contains(expected, StringComparison.OrdinalIgnoreCase);
1✔
371

372
    private static string? NormalizeTableOrderBy(string? orderBy)
373
    {
2✔
374
        if (string.IsNullOrWhiteSpace(orderBy))
2✔
375
        {
2✔
376
            return "table";
2✔
377
        }
378

NEW
379
        return orderBy.Equals("table", StringComparison.OrdinalIgnoreCase) ||
×
NEW
380
               orderBy.Equals("partitionKey", StringComparison.OrdinalIgnoreCase) ||
×
NEW
381
               orderBy.Equals("rowKey", StringComparison.OrdinalIgnoreCase)
×
NEW
382
            ? "table"
×
NEW
383
            : null;
×
384
    }
2✔
385

386
    private static string? NormalizeTableDirection(string? direction)
387
    {
2✔
388
        if (string.IsNullOrWhiteSpace(direction) ||
2✔
389
            direction.Equals("asc", StringComparison.OrdinalIgnoreCase))
2✔
390
        {
2✔
391
            return "asc";
2✔
392
        }
393

NEW
394
        return null;
×
395
    }
2✔
396

397
    private static string SanitizeTableKey(string value)
398
    {
2✔
399
        return value
2✔
400
            .Replace("/", "|")
2✔
401
            .Replace("\\", "|")
2✔
402
            .Replace("#", "_")
2✔
403
            .Replace("?", "_");
2✔
404
    }
2✔
405

406
    private static string GetString(TableEntity entity, string property) =>
407
        entity.TryGetValue(property, out var value) ? value?.ToString() ?? string.Empty : string.Empty;
20✔
408

409
    private static string? GetNullableString(TableEntity entity, string property) =>
410
        entity.TryGetValue(property, out var value) ? value?.ToString() : null;
10✔
411

412
    private static int GetInt32(TableEntity entity, string property) =>
413
        entity.TryGetValue(property, out var value) && value is int parsed ? parsed : 0;
3✔
414

415
    private static bool? GetNullableBool(TableEntity entity, string property) =>
NEW
416
        entity.TryGetValue(property, out var value) && value is bool parsed ? parsed : null;
×
417

418
    private static DateTimeOffset GetDateTimeOffset(TableEntity entity, string property) =>
419
        GetNullableDateTimeOffset(entity, property) ?? DateTimeOffset.MinValue;
3✔
420

421
    private static DateTimeOffset? GetNullableDateTimeOffset(TableEntity entity, string property) =>
422
        entity.TryGetValue(property, out var value) && value is DateTimeOffset parsed ? parsed : null;
3✔
423

424
    private static async Task<HttpResponseData> ServiceUnavailableAsync(
425
        HttpRequestData req,
426
        CancellationToken cancellationToken)
NEW
427
    {
×
NEW
428
        var response = req.CreateResponse(HttpStatusCode.ServiceUnavailable);
×
NEW
429
        await response.WriteAsJsonAsync(
×
NEW
430
            new { Error = "Azure storage is not configured. Set AzureWebJobsStorage to enable storage query endpoints." },
×
NEW
431
            cancellationToken);
×
NEW
432
        return response;
×
NEW
433
    }
×
434

435
    private static async Task<HttpResponseData> ErrorAsync(
436
        HttpRequestData req,
437
        string message,
438
        CancellationToken cancellationToken)
NEW
439
    {
×
NEW
440
        var response = req.CreateResponse(HttpStatusCode.InternalServerError);
×
NEW
441
        await response.WriteAsJsonAsync(new { Error = message }, cancellationToken);
×
NEW
442
        return response;
×
NEW
443
    }
×
444

445
    private static async Task<HttpResponseData> InvalidPagedTableSortAsync(
446
        HttpRequestData req,
447
        CancellationToken cancellationToken)
NEW
448
    {
×
NEW
449
        var response = req.CreateResponse(HttpStatusCode.BadRequest);
×
NEW
450
        await response.WriteAsJsonAsync(
×
NEW
451
            new { Error = "Continuation-token paging supports only table order. Use orderBy=table and direction=asc." },
×
NEW
452
            cancellationToken);
×
NEW
453
        return response;
×
NEW
454
    }
×
455

456
    private sealed record QueryPageResult<T>(IReadOnlyList<T> Items, string? NextContinuationToken);
12✔
457
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc