Skip to content

Commit 6b16993

Browse files
committed
cache log analysis & fetch it from the frontend
The backend now saves a pre-parsed representation of the log to storage, and returns a SAS URI which the frontend uses to fetch it. This should significantly reduce bandwidth usage and response times, by avoiding the repeated parsing and double data transfer. This implementation is messy, but it's an intermediate step towards later refactoring.
1 parent 111c735 commit 6b16993

File tree

13 files changed

+505
-442
lines changed

13 files changed

+505
-442
lines changed

docs/release-notes.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
# Release notes
44
## Upcoming release
55
* For the web UI:
6-
* Improved JSON parser code display:
6+
* Improved JSON parser:
77
* You can now hover/click braces to highlight matching pairs.
88
* You can now hover and click 'Copy' on the top-right to copy the full code to the clipboard.
99
* Updated to newer syntax highlighting library.
1010
* Fixed some JSON files breaking page layout.
11+
* Improved log parser:
12+
* Mods which failed to load are now shown in the mod list (with 'failed to load' in the error column).
13+
* Reduced response times with a new analysis cache and client-side fetch.
14+
* Removed support for very old SMAPI logs.
15+
* You can now download a JSON representation of the parsed log (see the download link at the bottom of the log page).
1116
* Third-party libraries are now served from `smapi.io` instead of external CDNs.
12-
* Removed support for very old SMAPI logs.
1317

1418
## 4.2.1
1519
Released 25 March 2025 for Stardew Valley 1.6.14 or later.

src/SMAPI.Web/Controllers/JsonValidatorController.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ public async Task<ViewResult> Index(string? schemaName = null, string? id = null
8282
this.Response.Headers["X-Robots-Tag"] = "noindex";
8383

8484
// fetch raw JSON
85-
StoredFileInfo file = await this.Storage.GetAsync(id!, renew);
86-
if (string.IsNullOrWhiteSpace(file.Content))
85+
StoredFileInfo file = await this.Storage.GetAsync(id!, renew, forceDownloadContent: true);
86+
if (string.IsNullOrWhiteSpace(file.FetchedData))
8787
return this.View("Index", result.SetUploadError("The JSON file seems to be empty."));
88-
result.SetContent(file.Content, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry, uploadWarning: file.Warning);
88+
result.SetContent(file.FetchedData, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry, uploadWarning: file.Warning);
8989

9090
// skip parsing if we're going to the edit screen
9191
if (isEditView)
@@ -102,7 +102,7 @@ public async Task<ViewResult> Index(string? schemaName = null, string? id = null
102102
};
103103
try
104104
{
105-
parsed = JToken.Parse(file.Content, settings);
105+
parsed = JToken.Parse(file.FetchedData, settings);
106106
}
107107
catch (JsonReaderException ex)
108108
{
@@ -159,12 +159,13 @@ public async Task<ActionResult> PostAsync(JsonValidatorRequestModel? request)
159159
return this.View("Index", this.GetModel(null, schemaName, isEditView: true).SetUploadError("The JSON file seems to be empty."));
160160

161161
// upload file
162-
UploadResult result = await this.Storage.SaveAsync(input);
162+
string id = Guid.NewGuid().ToString("N");
163+
UploadResult result = await this.Storage.SaveAsync(id, input);
163164
if (!result.Succeeded)
164-
return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null, null).SetUploadError(result.UploadError));
165+
return this.View("Index", this.GetModel(id, schemaName, isEditView: true).SetContent(input, null, null).SetUploadError(result.UploadError));
165166

166167
// redirect to view
167-
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName, id = result.ID })!);
168+
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName, id })!);
168169
}
169170

170171

src/SMAPI.Web/Controllers/LogParserController.cs

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Threading.Tasks;
66
using System.Web;
77
using Microsoft.AspNetCore.Mvc;
8+
using Newtonsoft.Json;
89
using StardewModdingAPI.Toolkit.Utilities;
910
using StardewModdingAPI.Web.Framework;
1011
using StardewModdingAPI.Web.Framework.LogParsing;
@@ -56,25 +57,52 @@ public async Task<ActionResult> Index(string? id = null, LogViewFormat format =
5657
// don't index uploaded logs in search engines
5758
this.Response.Headers["X-Robots-Tag"] = "noindex";
5859

59-
// fetch log
60-
StoredFileInfo file = await this.Storage.GetAsync(id, renew);
60+
// fetch log info
61+
StoredFileInfo file = await this.Storage.GetAsync($"parsed-{id}", renew);
62+
string? fetchUri = file.FetchUri;
63+
ParsedLog? rawData = null;
64+
if (file.FetchedData != null)
65+
{
66+
fetchUri = null;
67+
rawData = new LogParser().Parse(file.FetchedData);
68+
}
69+
70+
// TEMPORARY: support logs created before 2025-04-07.
71+
if (!file.Success)
72+
{
73+
StoredFileInfo fallbackFile = await this.Storage.GetAsync(id, renew, forceDownloadContent: true);
74+
if (fallbackFile.Success)
75+
{
76+
file = fallbackFile;
77+
fetchUri = null;
78+
rawData = new LogParser().Parse(file.FetchedData);
79+
}
80+
}
81+
82+
// wrap error if no data available
83+
if (fetchUri is null)
84+
rawData ??= new ParsedLog { Error = file.Error ?? "An unknown error occurred." };
6185

6286
// render view
6387
switch (format)
6488
{
6589
case LogViewFormat.Default:
6690
case LogViewFormat.RawView:
6791
{
68-
ParsedLog log = file.Success
69-
? new LogParser().Parse(file.Content)
70-
: new ParsedLog { IsValid = false, Error = file.Error };
71-
72-
return this.View("Index", this.GetModel(id, uploadWarning: file.Warning, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry).SetResult(log, showRaw: format == LogViewFormat.RawView));
92+
LogParserModel model = this
93+
.GetModel(id, uploadWarning: file.Warning, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry)
94+
.SetResult(fetchUri, rawData, showRaw: format == LogViewFormat.RawView);
95+
return this.View("Index", model);
7396
}
7497

7598
case LogViewFormat.RawDownload:
99+
// from fetch URL
100+
if (file.FetchUri != null)
101+
return this.Redirect(file.FetchUri);
102+
103+
// from raw content
76104
{
77-
string content = file.Error ?? file.Content ?? string.Empty;
105+
string content = file.FetchedData ?? file.Error ?? string.Empty;
78106
return this.File(Encoding.UTF8.GetBytes(content), "plain/text", $"SMAPI log ({id}).txt");
79107
}
80108

@@ -102,13 +130,17 @@ public async Task<ActionResult> PostAsync()
102130
return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty."));
103131
}
104132

133+
// parse log
134+
ParsedLog log = new LogParser().Parse(input);
135+
105136
// upload log
106-
UploadResult uploadResult = await this.Storage.SaveAsync(input);
137+
string id = Guid.NewGuid().ToString("N");
138+
UploadResult uploadResult = await this.Storage.SaveAsync($"parsed-{id}", JsonConvert.SerializeObject(log));
107139
if (!uploadResult.Succeeded)
108140
return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
109141

110142
// redirect to view
111-
return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id = uploadResult.ID })!);
143+
return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id })!);
112144
}
113145

114146

src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public class LogModInfo
2424
public string Author { get; }
2525

2626
/// <summary>The mod version.</summary>
27-
public string Version { get; private set; }
27+
public string? Version { get; private set; }
2828

2929
/// <summary>The mod description.</summary>
3030
public string Description { get; }

src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,33 @@ public class ParsedLog
5454

5555
/// <summary>The log messages.</summary>
5656
public LogMessage[] Messages { get; set; } = [];
57+
58+
59+
/*********
60+
** Public methods
61+
*********/
62+
/// <summary>Construct an empty instance.</summary>
63+
public ParsedLog() { }
64+
65+
/// <summary>Construct an instance.</summary>
66+
/// <param name="log">The other log instance to copy.</param>
67+
public ParsedLog(ParsedLog log)
68+
{
69+
// metadata
70+
this.IsValid = log.IsValid;
71+
this.Error = log.Error;
72+
this.RawText = log.RawText;
73+
this.IsSplitScreen = log.IsSplitScreen;
74+
75+
// log data
76+
this.ApiVersion = log.ApiVersion;
77+
this.ApiVersionParsed = log.ApiVersionParsed;
78+
this.GameVersion = log.GameVersion;
79+
this.OperatingSystem = log.OperatingSystem;
80+
this.GamePath = log.GamePath;
81+
this.ModPath = log.ModPath;
82+
this.Timestamp = log.Timestamp;
83+
this.Mods = log.Mods;
84+
this.Messages = log.Messages;
85+
}
5786
}

src/SMAPI.Web/Framework/Storage/IStorageProvider.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ namespace StardewModdingAPI.Web.Framework.Storage;
66
internal interface IStorageProvider
77
{
88
/// <summary>Save a text file to storage.</summary>
9+
/// <param name="id">The ID with which to save the result.</param>
910
/// <param name="content">The content to upload.</param>
1011
/// <param name="compress">Whether to gzip the text.</param>
1112
/// <returns>Returns metadata about the save attempt.</returns>
12-
Task<UploadResult> SaveAsync(string content, bool compress = true);
13+
Task<UploadResult> SaveAsync(string id, string content, bool compress = true);
1314

1415
/// <summary>Fetch raw text from storage.</summary>
15-
/// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param>
16+
/// <param name="id">The storage ID used to upload the file.</param>
1617
/// <param name="forceRenew">Whether to reset the file expiry.</param>
17-
Task<StoredFileInfo> GetAsync(string id, bool forceRenew);
18+
/// <param name="forceDownloadContent">Whether to download the file content even if a fetch URI can be provided.</param>
19+
Task<StoredFileInfo> GetAsync(string id, bool forceRenew, bool forceDownloadContent = false);
1820
}

src/SMAPI.Web/Framework/Storage/StorageProvider.cs

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Azure;
77
using Azure.Storage.Blobs;
88
using Azure.Storage.Blobs.Models;
9+
using Azure.Storage.Sas;
910
using Microsoft.Extensions.Options;
1011
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
1112
using StardewModdingAPI.Web.Framework.Compression;
@@ -53,16 +54,14 @@ public StorageProvider(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient
5354
}
5455

5556
/// <inheritdoc />
56-
public async Task<UploadResult> SaveAsync(string content, bool compress = true)
57+
public async Task<UploadResult> SaveAsync(string id, string content, bool compress = true)
5758
{
58-
string id = Guid.NewGuid().ToString("N");
59-
6059
// save to Azure
6160
if (this.HasAzure)
6261
{
6362
try
6463
{
65-
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
64+
await using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
6665
BlobClient blob = this.GetAzureBlobClient(id);
6766
await blob.UploadAsync(stream);
6867

@@ -80,42 +79,53 @@ public async Task<UploadResult> SaveAsync(string content, bool compress = true)
8079
string path = this.GetDevFilePath(id);
8180
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
8281

83-
File.WriteAllText(path, content);
82+
await File.WriteAllTextAsync(path, content);
8483
return new UploadResult(id, null);
8584
}
8685
}
8786

8887
/// <inheritdoc />
89-
public async Task<StoredFileInfo> GetAsync(string id, bool forceRenew)
88+
public async Task<StoredFileInfo> GetAsync(string id, bool forceRenew, bool forceDownloadContent = false)
9089
{
9190
// fetch from blob storage
92-
if (Guid.TryParseExact(id, "N", out Guid _))
91+
bool isBlobStorage = id.StartsWith("parsed-")
92+
? Guid.TryParseExact(id.Substring("parsed-".Length), "N", out _)
93+
: Guid.TryParseExact(id, "N", out _);
94+
if (isBlobStorage)
9395
{
9496
// Azure Blob storage
9597
if (this.HasAzure)
9698
{
9799
try
98100
{
99-
// get client
100-
BlobClient blob = this.GetAzureBlobClient(id);
101-
102-
// fetch file
103-
Response<BlobDownloadInfo> response = await blob.DownloadAsync();
104-
using BlobDownloadInfo result = response.Value;
105-
using StreamReader reader = new(result.Content);
106-
DateTimeOffset oldExpiry = this.GetExpiry(result.Details.LastModified);
107-
string content = this.GzipHelper.DecompressString(await reader.ReadToEndAsync());
101+
// fetch metadata
102+
BlobClient blobClient = this.GetAzureBlobClient(id);
103+
Response<BlobProperties> properties = await blobClient.GetPropertiesAsync();
104+
DateTimeOffset lastModified = properties.Value.LastModified;
105+
DateTimeOffset oldExpiry = this.GetExpiry(lastModified);
106+
107+
// get content or URL file
108+
string? content = null;
109+
string? fetchUri = null;
110+
if (forceDownloadContent)
111+
{
112+
Response<BlobDownloadInfo> response = await blobClient.DownloadAsync();
113+
using StreamReader reader = new(response.Value.Content);
114+
content = this.GzipHelper.DecompressString(await reader.ReadToEndAsync());
115+
}
116+
else
117+
fetchUri = blobClient.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.UtcNow.AddMinutes(5)).ToString();
108118

109119
// extend expiry if needed
110120
DateTimeOffset newExpiry = oldExpiry;
111-
if (forceRenew || this.IsWithinAutoRenewalWindow(result.Details.LastModified))
121+
if (forceRenew || this.IsWithinAutoRenewalWindow(lastModified))
112122
{
113-
await blob.SetMetadataAsync(new Dictionary<string, string> { ["expiryRenewed"] = DateTime.UtcNow.ToString("O") }); // change the blob's last-modified date (the specific property set doesn't matter)
123+
await blobClient.SetMetadataAsync(new Dictionary<string, string> { ["expiryRenewed"] = DateTime.UtcNow.ToString("O") }); // change the blob's last-modified date (the specific property set doesn't matter)
114124
newExpiry = this.GetExpiry(DateTimeOffset.UtcNow);
115125
}
116126

117127
// build model
118-
return new StoredFileInfo(content, oldExpiry, newExpiry);
128+
return new StoredFileInfo(fetchUri, content, oldExpiry, newExpiry);
119129
}
120130
catch (RequestFailedException ex)
121131
{
@@ -135,9 +145,7 @@ public async Task<StoredFileInfo> GetAsync(string id, bool forceRenew)
135145
if (file.Exists && file.LastWriteTimeUtc.AddDays(this.ExpiryDays) < DateTime.UtcNow) // expired
136146
file.Delete();
137147
if (!file.Exists)
138-
{
139148
return new StoredFileInfo(error: "There's no file with that ID.");
140-
}
141149

142150
// renew
143151
if (forceRenew)
@@ -148,7 +156,8 @@ public async Task<StoredFileInfo> GetAsync(string id, bool forceRenew)
148156

149157
// build model
150158
return new StoredFileInfo(
151-
content: await File.ReadAllTextAsync(file.FullName),
159+
fetchUri: null,
160+
fetchedData: await File.ReadAllTextAsync(file.FullName),
152161
oldExpiry: null,
153162
newExpiry: DateTime.UtcNow.AddDays(this.ExpiryDays),
154163
warning: "This file was saved temporarily to the local computer. This should only happen in a local development environment."
@@ -161,7 +170,7 @@ public async Task<StoredFileInfo> GetAsync(string id, bool forceRenew)
161170
{
162171
PasteInfo response = await this.Pastebin.GetAsync(id);
163172
response.Content = this.GzipHelper.DecompressString(response.Content);
164-
return new StoredFileInfo(response.Content, null, null, error: response.Error);
173+
return new StoredFileInfo(null, response.Content, null, null, error: response.Error);
165174
}
166175
}
167176

src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ internal class StoredFileInfo
1010
** Accessors
1111
*********/
1212
/// <summary>Whether the file was successfully fetched.</summary>
13-
[MemberNotNullWhen(true, nameof(StoredFileInfo.Content))]
14-
public bool Success => this.Content != null && this.Error == null;
13+
public bool Success => (this.FetchedData != null || this.FetchUri != null) && this.Error == null;
1514

16-
/// <summary>The fetched file content (if <see cref="Success"/> is <c>true</c>).</summary>
17-
public string? Content { get; }
15+
/// <summary>The URI from which to fetch the file, if available.</summary>
16+
public string? FetchUri { get; }
17+
18+
/// <summary>The pre-fetched data, if a <see name="FetchUri"/> can't be provided for this file.</summary>
19+
public string? FetchedData { get; }
1820

1921
/// <summary>When the file would no longer have been available, before any renewal applied in this request.</summary>
2022
public DateTimeOffset? OldExpiry { get; }
@@ -33,14 +35,16 @@ internal class StoredFileInfo
3335
** Public methods
3436
*********/
3537
/// <summary>Construct an instance.</summary>
36-
/// <param name="content">The fetched file content (if <see cref="Success"/> is <c>true</c>).</param>
38+
/// <param name="fetchUri">The URI from which to fetch the file, if available.</param>
39+
/// <param name="fetchedData">The pre-fetched data, if a <paramref name="fetchUri"/> can't be provided for this file.</param>
3740
/// <param name="oldExpiry">When the file would no longer have been available, before any renewal applied in this request.</param>
3841
/// <param name="newExpiry">When the file will no longer be available, after any renewal applied in this request.</param>
3942
/// <param name="warning">The error message if saving succeeded, but a non-blocking issue was encountered.</param>
4043
/// <param name="error">The error message if saving failed.</param>
41-
public StoredFileInfo(string? content, DateTimeOffset? oldExpiry, DateTimeOffset? newExpiry, string? warning = null, string? error = null)
44+
public StoredFileInfo(string? fetchUri, string? fetchedData, DateTimeOffset? oldExpiry, DateTimeOffset? newExpiry, string? warning = null, string? error = null)
4245
{
43-
this.Content = content;
46+
this.FetchUri = fetchUri;
47+
this.FetchedData = fetchedData;
4448
this.OldExpiry = oldExpiry;
4549
this.NewExpiry = newExpiry;
4650
this.Warning = warning;

src/SMAPI.Web/Framework/Storage/UploadResult.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ internal class UploadResult
99
** Accessors
1010
*********/
1111
/// <summary>Whether the file upload succeeded.</summary>
12-
[MemberNotNullWhen(true, nameof(UploadResult.ID))]
12+
[MemberNotNullWhen(true, nameof(UploadResult.Id))]
1313
[MemberNotNullWhen(false, nameof(UploadResult.UploadError))]
14-
public bool Succeeded => this.ID != null && this.UploadError == null;
14+
public bool Succeeded => this.Id != null && this.UploadError == null;
1515

1616
/// <summary>The file ID, if applicable.</summary>
17-
public string? ID { get; }
17+
public string? Id { get; }
1818

1919
/// <summary>The upload error, if any.</summary>
2020
public string? UploadError { get; }
@@ -28,7 +28,7 @@ internal class UploadResult
2828
/// <param name="uploadError">The upload error, if any.</param>
2929
public UploadResult(string? id, string? uploadError)
3030
{
31-
this.ID = id;
31+
this.Id = id;
3232
this.UploadError = uploadError;
3333
}
3434
}

0 commit comments

Comments
 (0)