Skip to content

Commit 9face8b

Browse files
authored
Merge pull request #440 from KodrAus/feat/sample-metrics
Initial metric support in `sample ingest`
2 parents 9b76212 + 3b1c27d commit 9face8b

20 files changed

Lines changed: 326 additions & 27 deletions

src/Roastery/Api/OrdersController.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Net;
33
using System.Threading.Tasks;
44
using Roastery.Data;
5+
using Roastery.Metrics;
56
using Roastery.Model;
67
using Roastery.Web;
78
using Serilog;
@@ -15,8 +16,8 @@ class OrdersController : Controller
1516
{
1617
readonly Database _database;
1718

18-
public OrdersController(ILogger logger, Database database)
19-
: base(logger)
19+
public OrdersController(ILogger logger, RoasteryMetrics metrics, Database database)
20+
: base(logger, metrics)
2021
{
2122
_database = database;
2223
}
@@ -41,7 +42,10 @@ public async Task<HttpResponse> Create(HttpRequest request)
4142
}
4243

4344
await _database.InsertAsync(order);
45+
46+
Metrics.RecordOrderCreated();
4447
Log.Information("Created new order {OrderId} for customer {CustomerName}", order.Id, order.CustomerName);
48+
4549
return Json(order, HttpStatusCode.Created);
4650
}
4751

@@ -65,7 +69,11 @@ public async Task<HttpResponse> Update(HttpRequest request)
6569
if (order.Status == OrderStatus.PendingShipment)
6670
Log.Information("Order placed and ready for shipment");
6771
else if (order.Status == OrderStatus.Shipped)
68-
Log.Information("Order shipped to {CustomerName} at {ShippingAddress}", order.CustomerName, order.ShippingAddress);
72+
{
73+
Metrics.RecordOrderShipped();
74+
Log.Information("Order shipped to {CustomerName} at {ShippingAddress}", order.CustomerName,
75+
order.ShippingAddress);
76+
}
6977
else
7078
Log.Information("Order updated");
7179

src/Roastery/Api/ProductsController.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Threading.Tasks;
22
using Roastery.Data;
3+
using Roastery.Metrics;
34
using Roastery.Model;
45
using Roastery.Web;
56
using Serilog;
@@ -12,8 +13,8 @@ class ProductsController : Controller
1213
{
1314
readonly Database _database;
1415

15-
public ProductsController(ILogger logger, Database database)
16-
: base(logger)
16+
public ProductsController(ILogger logger, RoasteryMetrics metrics, Database database)
17+
: base(logger, metrics)
1718
{
1819
_database = database;
1920
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace Roastery.Metrics;
5+
6+
public class ExponentialHistogram
7+
{
8+
public ExponentialHistogram(int initialScale = 20, int targetBuckets = 160)
9+
{
10+
_scale = initialScale;
11+
_targetBuckets = targetBuckets;
12+
_buckets = new Dictionary<double, ulong>();
13+
}
14+
15+
readonly int _targetBuckets;
16+
17+
int _scale;
18+
Dictionary<double, ulong> _buckets;
19+
20+
double _min;
21+
double _max;
22+
ulong _total;
23+
24+
public void Record(double rawValue)
25+
{
26+
_min = Math.Min(_min, rawValue);
27+
_max = Math.Max(_max, rawValue);
28+
_total += 1;
29+
30+
var midpoint = Midpoint(_scale, rawValue);
31+
_buckets.TryAdd(midpoint, 0);
32+
_buckets[midpoint] += 1;
33+
34+
if (_buckets.Count <= _targetBuckets) return;
35+
36+
// Rescale
37+
var newScale = _scale - 1;
38+
var newBuckets = new Dictionary<double, ulong>();
39+
40+
foreach (var (oldMidpoint, count) in _buckets)
41+
{
42+
var newMidpoint = Midpoint(_scale, oldMidpoint);
43+
newBuckets.TryAdd(newMidpoint, 0);
44+
newBuckets[newMidpoint] += count;
45+
}
46+
47+
_buckets = newBuckets;
48+
_scale = newScale;
49+
}
50+
51+
static double Midpoint(int scale, double rawValue)
52+
{
53+
var gamma = Math.Pow(2d, Math.Pow(2d, -scale));
54+
var index = Math.Abs(Math.Log(rawValue, gamma));
55+
56+
return (Math.Pow(gamma, index - 1) + Math.Pow(gamma, index)) / 2;
57+
}
58+
59+
public IReadOnlyDictionary<double, ulong> Buckets => _buckets;
60+
public int Scale => _scale;
61+
62+
public double Min => _min;
63+
public double Max => _max;
64+
public ulong Total => _total;
65+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace Roastery.Metrics;
2+
3+
public record struct PropertyNameMapping(string MetricDefinitions, string MetricSamples);
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.Linq;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Serilog;
8+
using Serilog.Events;
9+
using Serilog.Parsing;
10+
11+
namespace Roastery.Metrics;
12+
13+
public class RoasteryMetrics
14+
{
15+
public class Sample
16+
{
17+
/*
18+
Adding new metrics:
19+
20+
1. Add a new key type, `TKey` for the metric's attributes using structural equality.
21+
2. Add a `Dictionary<TKey, TMetric>` property for the metric where `TMetric` is its collection type.
22+
3. Add a method to `RoasterMetrics` to add a sample to the metric for a given key.
23+
4. Add support in `ToLogEvents` for the new metric.
24+
*/
25+
26+
// `HttpRequestDuration`: histogram
27+
public record struct HttpRequestDurationKey(string Path, int StatusCode);
28+
public readonly Dictionary<HttpRequestDurationKey, ExponentialHistogram> HttpRequestDuration = new();
29+
30+
// `OrdersCreated`: counter
31+
public ulong OrdersCreated;
32+
33+
// `OrdersShipped`: counter
34+
public ulong OrdersShipped;
35+
36+
static readonly MessageTemplate Template = new MessageTemplateParser().Parse("Metrics sampled");
37+
38+
public IEnumerable<LogEvent> ToLogEvents(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp)
39+
{
40+
foreach (var (key, metric) in HttpRequestDuration)
41+
{
42+
yield return ToLogEvent(
43+
logger,
44+
propertyNameMapping,
45+
timestamp,
46+
new Dictionary<string, object>
47+
{
48+
{ "HttpRequestDuration", new { kind = "Exponential", unit = "ms", description = "The time taken to fully process a request" } }
49+
},
50+
new
51+
{
52+
HttpRequestDuration = new {
53+
buckets = metric.Buckets
54+
.Select(bucket => new { midpoint = bucket.Key, count = bucket.Value }).ToArray(),
55+
scale = metric.Scale,
56+
min = metric.Min,
57+
max = metric.Max,
58+
count = metric.Total
59+
},
60+
key.Path,
61+
key.StatusCode
62+
}
63+
);
64+
}
65+
66+
yield return ToLogEvent(
67+
logger,
68+
propertyNameMapping,
69+
timestamp,
70+
new Dictionary<string, object>
71+
{
72+
{ "OrdersCreated", new { kind = "Counter", unit = "orders", description = "The total number of orders created in the system" } },
73+
{ "OrdersShipped", new { kind = "Counter", unit = "orders", description = "The total number of orders shipped in the system" } }
74+
},
75+
new
76+
{
77+
OrdersCreated,
78+
OrdersShipped
79+
}
80+
);
81+
}
82+
83+
static LogEvent ToLogEvent(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp, Dictionary<string, object> definitions, object samples)
84+
{
85+
logger.BindProperty(propertyNameMapping.MetricDefinitions, definitions, true, out var definitionsProperty);
86+
logger.BindProperty(propertyNameMapping.MetricSamples, samples, true, out var sampleProperty);
87+
88+
return new LogEvent(timestamp, LogEventLevel.Information, null, Template,
89+
[definitionsProperty!, sampleProperty!]);
90+
}
91+
}
92+
93+
// Access to the current sample is synchronized through a lock
94+
// This is a simple way to implement deltas for arbitrary types
95+
readonly Lock _lock = new();
96+
Sample _current = new();
97+
98+
public void RecordHttpRequestDuration(Sample.HttpRequestDurationKey key, double rawValue)
99+
{
100+
lock (_lock)
101+
{
102+
if (!_current.HttpRequestDuration.TryGetValue(key, out var metric))
103+
{
104+
metric = new ExponentialHistogram();
105+
_current.HttpRequestDuration.Add(key, metric);
106+
}
107+
108+
metric.Record(rawValue);
109+
}
110+
}
111+
112+
public void RecordOrderCreated()
113+
{
114+
lock (_lock)
115+
{
116+
_current.OrdersCreated += 1;
117+
}
118+
}
119+
120+
public void RecordOrderShipped()
121+
{
122+
lock (_lock)
123+
{
124+
_current.OrdersShipped += 1;
125+
}
126+
}
127+
128+
public (DateTimeOffset, Sample) Take()
129+
{
130+
var timestamp = DateTimeOffset.UtcNow;
131+
132+
var current = new Sample();
133+
134+
lock (_lock)
135+
{
136+
(current, _current) = (_current, current);
137+
}
138+
139+
return (timestamp, current);
140+
}
141+
142+
public static Task PeriodicSample(
143+
RoasteryMetrics metrics,
144+
TimeSpan samplingInterval,
145+
Func<DateTimeOffset, Sample, CancellationToken, Task> sample,
146+
CancellationToken cancellationToken)
147+
{
148+
return Task.Run(async () =>
149+
{
150+
var waitFor = samplingInterval;
151+
while (!cancellationToken.IsCancellationRequested)
152+
{
153+
await Task.Delay(waitFor, cancellationToken);
154+
155+
var stopwatch = Stopwatch.StartNew();
156+
157+
try
158+
{
159+
var (timestamp, current) = metrics.Take();
160+
await sample(timestamp, current, cancellationToken);
161+
}
162+
catch
163+
{
164+
// Ignored
165+
}
166+
167+
// Account for the time taken to produce the sample when computing
168+
// the next interval to wait for
169+
var elapsed = stopwatch.Elapsed;
170+
waitFor = elapsed < samplingInterval ? samplingInterval - stopwatch.Elapsed : samplingInterval;
171+
}
172+
}, cancellationToken);
173+
}
174+
}

src/Roastery/Program.cs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
using System.Collections.Generic;
1+
using System;
2+
using System.Collections.Generic;
23
using System.Linq;
34
using System.Threading;
45
using System.Threading.Tasks;
56
using Roastery.Agents;
67
using Roastery.Api;
78
using Roastery.Data;
89
using Roastery.Fake;
10+
using Roastery.Metrics;
911
using Roastery.Util;
1012
using Roastery.Web;
1113
using Serilog;
@@ -15,22 +17,35 @@ namespace Roastery;
1517
// Named this way to make stack traces a little more believable :-)
1618
public static class Program
1719
{
18-
public static async Task Main(ILogger logger, CancellationToken cancellationToken = default)
20+
public static async Task Main(ILogger logger, PropertyNameMapping propertyNameMapping, CancellationToken cancellationToken = default)
1921
{
22+
var metrics = new RoasteryMetrics();
23+
2024
var webApplicationLogger = logger.ForContext("Application", "Roastery Web Frontend");
2125

26+
// Sample metrics
27+
var periodicSample = RoasteryMetrics.PeriodicSample(metrics, TimeSpan.FromSeconds(5), (timestamp, sample, ct) =>
28+
{
29+
foreach (var evt in sample.ToLogEvents(webApplicationLogger, propertyNameMapping, timestamp))
30+
{
31+
webApplicationLogger.Write(evt);
32+
}
33+
34+
return Task.CompletedTask;
35+
}, cancellationToken);
36+
2237
var database = new Database(webApplicationLogger, "roastery");
2338
DatabaseMigrator.Populate(database);
2439

2540
var client = new HttpClient(
2641
"https://roastery.datalust.co",
2742
new NetworkLatencyMiddleware(
28-
new RequestLoggingMiddleware(webApplicationLogger,
43+
new RequestLoggingMiddleware(webApplicationLogger, metrics,
2944
new SchedulingLatencyMiddleware(
3045
new FaultInjectionMiddleware(webApplicationLogger,
3146
new Router([
32-
new OrdersController(logger, database),
33-
new ProductsController(logger, database)
47+
new OrdersController(logger, metrics, database),
48+
new ProductsController(logger, metrics, database)
3449
], webApplicationLogger))))));
3550

3651
var agents = new List<Agent>();
@@ -46,5 +61,6 @@ public static async Task Main(ILogger logger, CancellationToken cancellationToke
4661
agents.Add(new ArchivingBatch(client, batchApplicationLogger));
4762

4863
await Task.WhenAll(agents.Select(a => Agent.Run(a, cancellationToken)));
64+
await periodicSample;
4965
}
5066
}

src/Roastery/Web/Controller.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
using System.Net;
2+
using Roastery.Metrics;
23
using Serilog;
34

45
namespace Roastery.Web;
56

67
abstract class Controller
78
{
89
protected ILogger Log { get; }
9-
10-
protected Controller(ILogger logger)
10+
protected RoasteryMetrics Metrics { get; }
11+
12+
protected Controller(ILogger logger, RoasteryMetrics metrics)
1113
{
1214
Log = logger.ForContext(GetType());
15+
Metrics = metrics;
1316
}
1417

1518
protected static HttpResponse Json(object? body, HttpStatusCode statusCode = HttpStatusCode.OK)

0 commit comments

Comments
 (0)