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

moconnell / yolo / 28364383804

29 Jun 2026 10:06AM UTC coverage: 86.623% (+3.7%) from 82.93%
28364383804

Pull #131

github

web-flow
Merge 7ad19579d into 58d55311c
Pull Request #131: Feat: Telemetry

404 of 440 branches covered (91.82%)

Branch coverage included in aggregate %.

696 of 802 new or added lines in 14 files covered. (86.78%)

3 existing lines in 1 file now uncovered.

3436 of 3993 relevant lines covered (86.05%)

23.91 hits per line

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

75.89
/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(
5✔
15
    IServiceProvider serviceProvider,
5✔
16
    ILogger<StorageQueryFunctions> logger)
5✔
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
    {
2✔
28
        if (!TryGetTableServiceClient(req, out var tableServiceClient))
2✔
NEW
29
            return await ServiceUnavailableAsync(req, cancellationToken);
×
30

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

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

49
            return await WritePagedResponseAsync(
2✔
50
                req,
2✔
51
                pageResult.Items,
2✔
52
                page,
2✔
53
                pageSize,
2✔
54
                orderBy,
2✔
55
                direction,
2✔
56
                cancellationToken,
2✔
57
                pageResult.NextContinuationToken,
2✔
58
                skipItems: false);
2✔
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
    }
2✔
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
    {
2✔
84
        if (!TryGetTableServiceClient(req, out var tableServiceClient))
2✔
NEW
85
            return await ServiceUnavailableAsync(req, cancellationToken);
×
86

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

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

105
            return await WritePagedResponseAsync(
2✔
106
                req,
2✔
107
                pageResult.Items,
2✔
108
                page,
2✔
109
                pageSize,
2✔
110
                orderBy,
2✔
111
                direction,
2✔
112
                cancellationToken,
2✔
113
                pageResult.NextContinuationToken,
2✔
114
                skipItems: false);
2✔
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
    }
2✔
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
    {
4✔
184
        tableServiceClient = serviceProvider.GetService<TableServiceClient>() ??
4✔
185
                             req.FunctionContext.InstanceServices.GetService<TableServiceClient>()!;
4✔
186

187
        return tableServiceClient is not null;
4✔
188
    }
4✔
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
    {
2✔
207
        var host = query.GetString("host");
2✔
208
        var endpoint = query.GetString("endpoint");
2✔
209
        var method = query.GetString("method");
2✔
210
        var statusCode = query.GetString("statusCode");
2✔
211
        var contentHash = query.GetString("contentHash");
2✔
212
        var from = query.GetDateTimeOffset("from");
2✔
213
        var to = query.GetDateTimeOffset("to");
2✔
214

215
        var tableClient = tableServiceClient.GetTableClient(HttpRequestsTableName);
2✔
216
        return await QueryFilteredEntitiesPageAsync(
2✔
217
            tableClient,
2✔
218
            BuildPartitionFilter(host),
2✔
219
            pageSize,
2✔
220
            continuationToken,
2✔
221
            ToHttpRequestCaptureQueryItem,
2✔
222
            item => Matches(item.Host, host) &&
4✔
223
                    Contains(item.Endpoint, endpoint) &&
4✔
224
                    Matches(item.Method, method) &&
4✔
225
                    (!int.TryParse(statusCode, out var expectedStatusCode) || item.StatusCode == expectedStatusCode) &&
4✔
226
                    Matches(item.ContentHash, contentHash) &&
4✔
227
                    (!from.HasValue || item.RequestTimeUtc >= from.Value) &&
4✔
228
                    (!to.HasValue || item.RequestTimeUtc <= to.Value),
4✔
229
            cancellationToken);
2✔
230
    }
2✔
231

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

248
        var tableClient = tableServiceClient.GetTableClient(RebalanceEventsTableName);
2✔
249
        var partitionFilter = !string.IsNullOrWhiteSpace(strategy) && !string.IsNullOrWhiteSpace(runId)
2✔
250
            ? BuildPartitionFilter($"{strategy}|{runId}")
2✔
251
            : null;
2✔
252
        return await QueryFilteredEntitiesPageAsync(
2✔
253
            tableClient,
2✔
254
            partitionFilter,
2✔
255
            pageSize,
2✔
256
            continuationToken,
2✔
257
            ToRebalanceEventQueryItem,
2✔
258
            item => Matches(item.StrategyName, strategy) &&
6✔
259
                    Matches(item.RunId, runId) &&
6✔
260
                    Matches(item.EventType, eventType) &&
6✔
261
                    Matches(item.Level, level) &&
6✔
262
                    Matches(item.Coin, coin) &&
6✔
263
                    Matches(item.ClientOrderId, clientOrderId) &&
6✔
264
                    (!from.HasValue || item.TimestampUtc >= from.Value) &&
6✔
265
                    (!to.HasValue || item.TimestampUtc <= to.Value),
6✔
266
            cancellationToken);
2✔
267
    }
2✔
268

269
    private static async Task<QueryPageResult<T>> QueryFilteredEntitiesPageAsync<T>(
270
        TableClient tableClient,
271
        string? filter,
272
        int pageSize,
273
        string? continuationToken,
274
        Func<TableEntity, T> map,
275
        Func<T, bool> predicate,
276
        CancellationToken cancellationToken)
277
    {
4✔
278
        var items = new List<T>(pageSize);
4✔
279

280
        await foreach (var page in tableClient
28✔
281
                           .QueryAsync<TableEntity>(
4✔
282
                               filter,
4✔
283
                               maxPerPage: 1,
4✔
284
                               cancellationToken: cancellationToken)
4✔
285
                           .AsPages(continuationToken, 1))
4✔
286
        {
9✔
287
            foreach (var entity in page.Values)
45✔
288
            {
10✔
289
                var item = map(entity);
10✔
290
                if (!predicate(item))
10✔
291
                {
4✔
292
                    continue;
4✔
293
                }
294

295
                items.Add(item);
6✔
296
                if (items.Count == pageSize)
6✔
297
                {
2✔
298
                    return new QueryPageResult<T>(items, page.ContinuationToken);
2✔
299
                }
300
            }
4✔
301
        }
7✔
302

303
        return new QueryPageResult<T>(items, null);
2✔
304
    }
4✔
305

306
    private static string? BuildPartitionFilter(string? partitionKey)
307
    {
3✔
308
        return string.IsNullOrWhiteSpace(partitionKey)
3✔
309
            ? null
3✔
310
            : $"PartitionKey eq '{SanitizeTableKey(partitionKey).Replace("'", "''")}'";
3✔
311
    }
3✔
312

313
    private static async Task<HttpResponseData> WritePagedResponseAsync<T>(
314
        HttpRequestData req,
315
        IReadOnlyList<T> items,
316
        int page,
317
        int pageSize,
318
        string? orderBy,
319
        string direction,
320
        CancellationToken cancellationToken,
321
        string? nextContinuationToken = null,
322
        bool skipItems = true)
323
    {
4✔
324
        var response = req.CreateResponse(HttpStatusCode.OK);
4✔
325
        var pageItems = skipItems
4✔
326
            ? items
4✔
327
                .Skip((page - 1) * pageSize)
4✔
328
                .Take(pageSize)
4✔
329
                .ToArray()
4✔
330
            : [.. items.Take(pageSize)];
4✔
331

332
        await response.WriteAsJsonAsync(
4✔
333
            new PagedQueryResponse<T>(pageItems, page, pageSize, items.Count, orderBy, direction, nextContinuationToken),
4✔
334
            cancellationToken);
4✔
335
        return response;
4✔
336
    }
4✔
337

338
    private static HttpRequestCaptureQueryItem ToHttpRequestCaptureQueryItem(TableEntity entity)
339
    {
4✔
340
        return new HttpRequestCaptureQueryItem(
4✔
341
            GetString(entity, "Host"),
4✔
342
            GetString(entity, "Endpoint"),
4✔
343
            GetString(entity, "Url"),
4✔
344
            GetString(entity, "Method"),
4✔
345
            GetInt32(entity, "StatusCode"),
4✔
346
            GetString(entity, "BlobContainer"),
4✔
347
            GetString(entity, "BlobName"),
4✔
348
            GetString(entity, "ContentHash"),
4✔
349
            GetDateTimeOffset(entity, "RequestTimeUtc"),
4✔
350
            GetString(entity, "QueryParametersJson"));
4✔
351
    }
4✔
352

353
    private static RebalanceEventQueryItem ToRebalanceEventQueryItem(TableEntity entity)
354
    {
6✔
355
        return new RebalanceEventQueryItem(
6✔
356
            GetString(entity, "RunId"),
6✔
357
            GetString(entity, "StrategyName"),
6✔
358
            GetDateTimeOffset(entity, "TimestampUtc"),
6✔
359
            GetInt32(entity, "Sequence"),
6✔
360
            GetString(entity, "EventType"),
6✔
361
            GetString(entity, "Level"),
6✔
362
            GetString(entity, "Summary"),
6✔
363
            GetNullableString(entity, "WalletAddress"),
6✔
364
            GetNullableString(entity, "VaultAddress"),
6✔
365
            GetNullableString(entity, "Coin"),
6✔
366
            GetNullableString(entity, "ClientOrderId"),
6✔
367
            GetNullableString(entity, "OrderId"),
6✔
368
            GetString(entity, "PayloadJson"));
6✔
369
    }
6✔
370

371
    private static bool Matches(string? actual, string? expected) =>
372
        string.IsNullOrWhiteSpace(expected) ||
37✔
373
        string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
37✔
374

375
    private static bool Contains(string actual, string? expected) =>
376
        string.IsNullOrWhiteSpace(expected) ||
4✔
377
        actual.Contains(expected, StringComparison.OrdinalIgnoreCase);
4✔
378

379
    private static string? NormalizeTableOrderBy(string? orderBy)
380
    {
4✔
381
        if (string.IsNullOrWhiteSpace(orderBy))
4✔
382
        {
4✔
383
            return "table";
4✔
384
        }
385

NEW
386
        return orderBy.Equals("table", StringComparison.OrdinalIgnoreCase) ||
×
NEW
387
               orderBy.Equals("partitionKey", StringComparison.OrdinalIgnoreCase) ||
×
NEW
388
               orderBy.Equals("rowKey", StringComparison.OrdinalIgnoreCase)
×
NEW
389
            ? "table"
×
NEW
390
            : null;
×
391
    }
4✔
392

393
    private static string? NormalizeTableDirection(string? direction)
394
    {
4✔
395
        if (string.IsNullOrWhiteSpace(direction) ||
4✔
396
            direction.Equals("asc", StringComparison.OrdinalIgnoreCase))
4✔
397
        {
4✔
398
            return "asc";
4✔
399
        }
400

NEW
401
        return null;
×
402
    }
4✔
403

404
    private static string SanitizeTableKey(string value)
405
    {
2✔
406
        return value
2✔
407
            .Replace("/", "|")
2✔
408
            .Replace("\\", "|")
2✔
409
            .Replace("#", "_")
2✔
410
            .Replace("?", "_");
2✔
411
    }
2✔
412

413
    private static string GetString(TableEntity entity, string property) =>
414
        entity.TryGetValue(property, out var value) ? value?.ToString() ?? string.Empty : string.Empty;
68✔
415

416
    private static string? GetNullableString(TableEntity entity, string property) =>
417
        entity.TryGetValue(property, out var value) ? value?.ToString() : null;
30✔
418

419
    private static int GetInt32(TableEntity entity, string property) =>
420
        entity.TryGetValue(property, out var value) && value is int parsed ? parsed : 0;
10✔
421

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

425
    private static DateTimeOffset GetDateTimeOffset(TableEntity entity, string property) =>
426
        GetNullableDateTimeOffset(entity, property) ?? DateTimeOffset.MinValue;
10✔
427

428
    private static DateTimeOffset? GetNullableDateTimeOffset(TableEntity entity, string property) =>
429
        entity.TryGetValue(property, out var value) && value is DateTimeOffset parsed ? parsed : null;
10✔
430

431
    private static async Task<HttpResponseData> ServiceUnavailableAsync(
432
        HttpRequestData req,
433
        CancellationToken cancellationToken)
NEW
434
    {
×
NEW
435
        var response = req.CreateResponse(HttpStatusCode.ServiceUnavailable);
×
NEW
436
        await response.WriteAsJsonAsync(
×
NEW
437
            new { Error = "Azure storage is not configured. Set AzureWebJobsStorage to enable storage query endpoints." },
×
NEW
438
            cancellationToken);
×
NEW
439
        return response;
×
NEW
440
    }
×
441

442
    private static async Task<HttpResponseData> ErrorAsync(
443
        HttpRequestData req,
444
        string message,
445
        CancellationToken cancellationToken)
NEW
446
    {
×
NEW
447
        var response = req.CreateResponse(HttpStatusCode.InternalServerError);
×
NEW
448
        await response.WriteAsJsonAsync(new { Error = message }, cancellationToken);
×
NEW
449
        return response;
×
NEW
450
    }
×
451

452
    private static async Task<HttpResponseData> InvalidPagedTableSortAsync(
453
        HttpRequestData req,
454
        CancellationToken cancellationToken)
NEW
455
    {
×
NEW
456
        var response = req.CreateResponse(HttpStatusCode.BadRequest);
×
NEW
457
        await response.WriteAsJsonAsync(
×
NEW
458
            new { Error = "Continuation-token paging supports only table order. Use orderBy=table and direction=asc." },
×
NEW
459
            cancellationToken);
×
NEW
460
        return response;
×
NEW
461
    }
×
462

463
    private sealed record QueryPageResult<T>(IReadOnlyList<T> Items, string? NextContinuationToken);
12✔
464
}
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