Skip to content

Commit f56cc66

Browse files
committed
Send logs to collector from native messaging host
1 parent 992b5de commit f56cc66

10 files changed

Lines changed: 478 additions & 176 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using BrowserGuard;
2+
using Xunit;
3+
4+
namespace BrowserGuard.Tests
5+
{
6+
public class NetLogEntryTests
7+
{
8+
[Fact]
9+
public void AcceptsAnEntry()
10+
{
11+
var rejection = NetLogEntry.Compact(
12+
"""{"operation":"browsing","url":"https://example.com/"}""", out var line);
13+
14+
Assert.Null(rejection);
15+
Assert.Equal("""{"operation":"browsing","url":"https://example.com/"}""", line);
16+
}
17+
18+
// A sender that pretty printed its entry must not break the one line per
19+
// entry rule the file depends on.
20+
[Fact]
21+
public void PutsAPrettyPrintedEntryOnASingleLine()
22+
{
23+
NetLogEntry.Compact(
24+
"{\n \"operation\": \"browsing\",\n \"url\": \"https://example.com/\"\n}",
25+
out var line);
26+
27+
Assert.DoesNotContain("\n", line);
28+
Assert.Equal("""{"operation":"browsing","url":"https://example.com/"}""", line);
29+
}
30+
31+
[Fact]
32+
public void LeavesJapaneseLegible()
33+
{
34+
NetLogEntry.Compact("""{"operation":"browsing","name":"ページ名"}""", out var line);
35+
36+
Assert.Contains("ページ名", line);
37+
}
38+
39+
[Fact]
40+
public void RefusesSomethingThatIsNotJson()
41+
{
42+
var rejection = NetLogEntry.Compact("not json at all", out var line);
43+
44+
Assert.Contains("not valid JSON", rejection);
45+
Assert.Equal("", line);
46+
}
47+
48+
// A bare string or an array is valid JSON but not a log entry.
49+
[Fact]
50+
public void RefusesJsonThatIsNotAnObject()
51+
{
52+
Assert.Contains("not a JSON object", NetLogEntry.Compact("\"browsing\"", out _));
53+
Assert.Contains("not a JSON object", NetLogEntry.Compact("[1,2,3]", out _));
54+
}
55+
}
56+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using System.Collections.Generic;
4+
using System.IO;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Text;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using BrowserGuard;
11+
using Xunit;
12+
13+
namespace BrowserGuard.Tests
14+
{
15+
public class NetLogSenderTests
16+
{
17+
const string Entry = """{"operation":"browsing","url":"https://example.com/"}""";
18+
19+
// Answers every request with a status the test chooses, and records what
20+
// it was sent, so the whole HttpClient path is exercised for real.
21+
sealed class Collector : HttpMessageHandler
22+
{
23+
internal readonly BlockingCollection<string> Bodies = new();
24+
internal readonly List<string> ContentTypes = new();
25+
internal HttpStatusCode Status = HttpStatusCode.OK;
26+
internal int FailuresLeft;
27+
internal Uri? LastUri;
28+
29+
protected override async Task<HttpResponseMessage> SendAsync(
30+
HttpRequestMessage request, CancellationToken cancellationToken)
31+
{
32+
LastUri = request.RequestUri;
33+
ContentTypes.Add(request.Content?.Headers.ContentType?.ToString() ?? "");
34+
Bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
35+
36+
if (FailuresLeft > 0)
37+
{
38+
FailuresLeft--;
39+
throw new HttpRequestException("the collector is unreachable");
40+
}
41+
return new HttpResponseMessage(Status);
42+
}
43+
}
44+
45+
static string Take(Collector collector) =>
46+
collector.Bodies.TryTake(out var body, TimeSpan.FromSeconds(5))
47+
? body
48+
: throw new TimeoutException("nothing was posted");
49+
50+
[Fact]
51+
public void PostsTheEntryToTheEndpoint()
52+
{
53+
var collector = new Collector();
54+
using var sender = new NetLogSender("https://collector.example.com/log", null, collector);
55+
56+
Assert.True(sender.Enqueue(Entry));
57+
58+
Assert.Equal(Entry, Take(collector));
59+
Assert.Equal(new Uri("https://collector.example.com/log"), collector.LastUri);
60+
Assert.Contains("application/json", collector.ContentTypes[0]);
61+
}
62+
63+
[Fact]
64+
public void PostsEveryEntryInTurn()
65+
{
66+
var collector = new Collector();
67+
using var sender = new NetLogSender("https://collector.example.com/log", null, collector);
68+
69+
for (var i = 0; i < 5; i++)
70+
{
71+
sender.Enqueue($$"""{"operation":"browsing","n":{{i}}}""");
72+
}
73+
74+
for (var i = 0; i < 5; i++)
75+
{
76+
Assert.Contains($"\"n\":{i}", Take(collector));
77+
}
78+
}
79+
80+
// The message loop must not wait on the network.
81+
[Fact]
82+
public void HandsTheEntryOverWithoutWaitingForTheCollector()
83+
{
84+
var collector = new Collector();
85+
using var sender = new NetLogSender("https://collector.example.com/log", null, collector);
86+
87+
var elapsed = System.Diagnostics.Stopwatch.StartNew();
88+
for (var i = 0; i < 100; i++)
89+
{
90+
sender.Enqueue(Entry);
91+
}
92+
elapsed.Stop();
93+
94+
Assert.True(elapsed.ElapsedMilliseconds < 1000,
95+
$"queueing 100 entries took {elapsed.ElapsedMilliseconds}ms");
96+
}
97+
98+
[Fact]
99+
public void TriesAgainAfterAFailure()
100+
{
101+
var collector = new Collector { FailuresLeft = 2 };
102+
using var sender = new NetLogSender("https://collector.example.com/log", null, collector);
103+
104+
sender.Enqueue(Entry);
105+
106+
Take(collector);
107+
Take(collector);
108+
Assert.Equal(Entry, Take(collector));
109+
}
110+
111+
// The browser closes the port on its way out, and the host follows.
112+
// Whatever is queued has to leave first.
113+
[Fact]
114+
public void SendsWhatIsQueuedBeforeItShutsDown()
115+
{
116+
var collector = new Collector();
117+
var sender = new NetLogSender("https://collector.example.com/log", null, collector);
118+
for (var i = 0; i < 10; i++)
119+
{
120+
sender.Enqueue(Entry);
121+
}
122+
123+
sender.Dispose();
124+
125+
Assert.Equal(10, collector.Bodies.Count);
126+
}
127+
128+
[Fact]
129+
public void TakesNoFurtherEntriesOnceItHasShutDown()
130+
{
131+
var collector = new Collector();
132+
var sender = new NetLogSender("https://collector.example.com/log", null, collector);
133+
sender.Dispose();
134+
135+
Assert.False(sender.Enqueue(Entry));
136+
}
137+
138+
// A collector that answers with an error must not stop the ones after it.
139+
[Fact]
140+
public void KeepsGoingAfterTheCollectorRefusesAnEntry()
141+
{
142+
var collector = new Collector { Status = HttpStatusCode.InternalServerError };
143+
using var sender = new NetLogSender("https://collector.example.com/log", null, collector);
144+
145+
sender.Enqueue("""{"operation":"first"}""");
146+
147+
// Three attempts at the first entry, then it moves on.
148+
Assert.Contains("first", Take(collector));
149+
Assert.Contains("first", Take(collector));
150+
Assert.Contains("first", Take(collector));
151+
152+
collector.Status = HttpStatusCode.OK;
153+
sender.Enqueue("""{"operation":"second"}""");
154+
Assert.Contains("second", Take(collector));
155+
}
156+
}
157+
}

BrowserGuard.Tests/NetLogWriterTests.cs

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -70,50 +70,6 @@ public void CreatesTheDirectory()
7070
Assert.True(File.Exists(Path.Combine(tempDir, "nested", "netlog.jsonl")));
7171
}
7272

73-
// A sender that pretty printed its entry must not break the one line per
74-
// entry rule the file depends on.
75-
[Fact]
76-
public void PutsAPrettyPrintedEntryOnASingleLine()
77-
{
78-
var writer = Writer();
79-
80-
writer.Write("{\n \"operation\": \"browsing\",\n \"url\": \"https://example.com/\"\n}");
81-
82-
Assert.Single(File.ReadAllLines(LogPath));
83-
}
84-
85-
[Fact]
86-
public void LeavesJapaneseLegible()
87-
{
88-
var writer = Writer();
89-
90-
writer.Write("""{"operation":"browsing","name":"ページ名"}""");
91-
92-
Assert.Contains("ページ名", File.ReadAllText(LogPath));
93-
}
94-
95-
[Fact]
96-
public void RefusesSomethingThatIsNotJson()
97-
{
98-
var writer = Writer();
99-
100-
var failure = writer.Write("not json at all");
101-
102-
Assert.Contains("not valid JSON", failure);
103-
Assert.False(File.Exists(LogPath));
104-
}
105-
106-
// A bare string or number is valid JSON but not a log entry.
107-
[Fact]
108-
public void RefusesJsonThatIsNotAnObject()
109-
{
110-
var writer = Writer();
111-
112-
Assert.Contains("not a JSON object", writer.Write("\"browsing\""));
113-
Assert.Contains("not a JSON object", writer.Write("[1,2,3]"));
114-
Assert.False(File.Exists(LogPath));
115-
}
116-
11773
[Fact]
11874
public void KeepsTheEarlierEntriesWhenItRotates()
11975
{

BrowserGuard/MessageHandler.cs

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ internal class ConfigResponse : Response
1717
public Config? Config { get; set; }
1818
}
1919

20-
internal class MessageHandler
20+
internal class MessageHandler : IDisposable
2121
{
2222
private readonly Logger? logger;
2323

24-
private NetLogWriter? netLog;
24+
private NetLogWriter? netLogFile;
25+
private NetLogSender? netLogSender;
2526
private bool netLogResolved;
2627

2728
// The logging configuration can be handed in so that it does not have to
@@ -33,8 +34,7 @@ internal MessageHandler(Logger? logger = null, NetLoggerConfig? netLoggerConfig
3334
{
3435
return;
3536
}
36-
netLog = Create(netLoggerConfig);
37-
netLogResolved = true;
37+
ResolveNetLog(netLoggerConfig);
3838
}
3939

4040
// null means nothing is sent back to the browser. Log entries arrive for
@@ -69,13 +69,25 @@ internal MessageHandler(Logger? logger = null, NetLoggerConfig? netLoggerConfig
6969

7070
private Response? HandleLogEntry(string entry)
7171
{
72-
var writer = NetLog();
73-
if (writer is null)
72+
ResolveNetLog();
73+
if (netLogFile is null && netLogSender is null)
7474
{
7575
return null;
7676
}
7777

78-
var failure = writer.Write(entry);
78+
// Checked once here, so that both destinations are handed the same
79+
// single line and a bad entry is refused before either sees it.
80+
var rejection = NetLogEntry.Compact(entry, out var line);
81+
if (rejection is not null)
82+
{
83+
return new Response { Success = false, Error = rejection };
84+
}
85+
86+
// The collector is best effort; only the file reports a failure,
87+
// because that is the copy the entry was meant to survive in.
88+
netLogSender?.Enqueue(line);
89+
90+
var failure = netLogFile?.Write(line);
7991
if (failure is null)
8092
{
8193
return null;
@@ -84,29 +96,38 @@ internal MessageHandler(Logger? logger = null, NetLoggerConfig? netLoggerConfig
8496
}
8597

8698
// Read once and remembered, including the decision not to log at all.
87-
private NetLogWriter? NetLog()
99+
private void ResolveNetLog()
88100
{
89101
if (netLogResolved)
90102
{
91-
return netLog;
103+
return;
92104
}
93-
netLogResolved = true;
94-
netLog = Create(ConfigLoader.LoadConfig().NetLogger);
95-
return netLog;
105+
ResolveNetLog(ConfigLoader.LoadConfig().NetLogger);
96106
}
97107

98-
// Both switches have to be on: NetLogger turns the whole feature off,
99-
// LocalFile only the copy kept on this machine.
100-
private NetLogWriter? Create(NetLoggerConfig config)
108+
// NetLogger turns the whole feature off; the two destinations are
109+
// independent of each other below that.
110+
private void ResolveNetLog(NetLoggerConfig config)
101111
{
102-
if (!config.Enabled || !config.LocalFile.Enabled)
112+
netLogResolved = true;
113+
if (!config.Enabled)
103114
{
104-
logger?.Log("Command: log entry, but local logging is disabled");
105-
return null;
115+
logger?.Log("Command: log entry, but logging is disabled");
116+
return;
117+
}
118+
119+
if (config.LocalFile.Enabled)
120+
{
121+
netLogFile = new NetLogWriter(config.LocalFile, logger);
122+
logger?.Log($"Command: log entry, writing to {netLogFile.FilePath}");
123+
}
124+
if (!string.IsNullOrWhiteSpace(config.Endpoint))
125+
{
126+
netLogSender = new NetLogSender(config.Endpoint, logger);
127+
logger?.Log($"Command: log entry, sending to {config.Endpoint}");
106128
}
107-
var writer = new NetLogWriter(config.LocalFile, logger);
108-
logger?.Log($"Command: log entry, writing to {writer.FilePath}");
109-
return writer;
110129
}
130+
131+
public void Dispose() => netLogSender?.Dispose();
111132
}
112133
}

0 commit comments

Comments
 (0)