Skip to content

Commit e097e72

Browse files
authored
Option to Attach full logs to messages (#1)
2 parents f294613 + 93166d4 commit e097e72

File tree

5 files changed

+399
-17
lines changed

5 files changed

+399
-17
lines changed

Sources/DiscordLogger/DiscordLogHandler.swift

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ public struct DiscordLogHandler: LogHandler {
8787
message: Logger.Message,
8888
metadata: Logger.Metadata?,
8989
source: String,
90-
file: String,
91-
function: String,
92-
line: UInt
90+
file: String = #fileID,
91+
function: String = #function,
92+
line: UInt = #line
9393
) {
9494
let config = logManager.configuration
9595

@@ -127,8 +127,22 @@ public struct DiscordLogHandler: LogHandler {
127127
})
128128
)
129129
)
130-
131-
Task { await logManager.include(address: address, embed: embed, level: level) }
130+
131+
let attachmentDisabled = logManager.configuration.sendFullLogAsAttachment.isDisabled
132+
let attachment = attachmentDisabled ? nil : LogInfo(
133+
level: level,
134+
message: "\(message)",
135+
metadata: allMetadata.mapValues(\.description)
136+
)
137+
138+
Task {
139+
await logManager.include(
140+
address: address,
141+
embed: embed,
142+
attachment: attachment,
143+
level: level
144+
)
145+
}
132146
}
133147
}
134148

Sources/DiscordLogger/DiscordLogManager.swift

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,50 @@ public actor DiscordLogManager {
6161
}
6262
}
6363
}
64-
64+
65+
public enum LogAttachmentPolicy: Sendable {
66+
case disabled
67+
/// Use ``enabled(formatter:)`` for better type-inference by compiler.
68+
/// Otherwise you can use this too.
69+
case enabled(_formatter: any LogFormatter = .json)
70+
71+
/// Enable the log attachments.
72+
public static var enabled: Self {
73+
.enabled(formatter: .json)
74+
}
75+
76+
/// Enable the log attachments.
77+
///
78+
/// To enable compiler-helps with `LogFormatter`-extension inferences like `.json`.
79+
/// Uses `some LogFormatter` instead of `any LogFormatter` for this reason.
80+
///
81+
/// https://github.com/apple/swift/issues/68269
82+
public static func enabled(formatter: some LogFormatter = .json) -> Self {
83+
.enabled(_formatter: formatter)
84+
}
85+
86+
var formatter: (any LogFormatter)? {
87+
switch self {
88+
case .disabled:
89+
return nil
90+
case .enabled(let formatter):
91+
return formatter
92+
}
93+
}
94+
95+
var isDisabled: Bool {
96+
switch self {
97+
case .disabled:
98+
return true
99+
case .enabled:
100+
return false
101+
}
102+
}
103+
}
104+
65105
let frequency: Duration
66106
let aliveNotice: AliveNotice?
107+
let sendFullLogAsAttachment: LogAttachmentPolicy
67108
let mentions: [Logger.Level: [String]]
68109
let colors: [Logger.Level: DiscordColor]
69110
let excludeMetadata: Set<Logger.Level>
@@ -76,6 +117,8 @@ public actor DiscordLogManager {
76117
/// - frequency: The frequency of the log-sendings. e.g. if its set to 30s, logs will only be sent once-in-30s. Should not be lower than 10s, because of Discord rate-limits.
77118
/// - aliveNotice: Configuration for sending "I am alive" messages every once in a while. Note that alive notices are delayed until it's been `interval`-time past last message.
78119
/// e.g. `Logger(label: "Fallback", factory: StreamLogHandler.standardOutput(label:))`
120+
/// - sendFullLogAsAttachment: Whether or not to send the full log as an attachment.
121+
/// The normal logs might need to truncate some stuff when sending as embeds, due to Discord limits.
79122
/// - mentions: ID of users/roles to be mentioned for each log-level.
80123
/// - colors: Color of the embeds to be used for each log-level.
81124
/// - excludeMetadata: Excludes all metadata for these log-levels.
@@ -86,6 +129,7 @@ public actor DiscordLogManager {
86129
public init(
87130
frequency: Duration = .seconds(10),
88131
aliveNotice: AliveNotice? = nil,
132+
sendFullLogAsAttachment: LogAttachmentPolicy = .disabled,
89133
mentions: [Logger.Level: Mention] = [:],
90134
colors: [Logger.Level: DiscordColor] = [
91135
.critical: .purple,
@@ -104,6 +148,7 @@ public actor DiscordLogManager {
104148
) {
105149
self.frequency = frequency
106150
self.aliveNotice = aliveNotice
151+
self.sendFullLogAsAttachment = sendFullLogAsAttachment
107152
self.mentions = mentions.mapValues { $0.toMentionStrings() }
108153
self.colors = colors
109154
self.excludeMetadata = excludeMetadata
@@ -116,6 +161,7 @@ public actor DiscordLogManager {
116161

117162
struct Log: CustomStringConvertible {
118163
let embed: Embed
164+
let attachment: LogInfo?
119165
let level: Logger.Level?
120166
let isFirstAliveNotice: Bool
121167

@@ -163,13 +209,25 @@ public actor DiscordLogManager {
163209
self.fallbackLogger = new ?? Logger(label: "DBM.LogManager")
164210
}
165211

166-
func include(address: WebhookAddress, embed: Embed, level: Logger.Level) {
167-
self.include(address: address, embed: embed, level: level, isFirstAliveNotice: false)
212+
func include(
213+
address: WebhookAddress,
214+
embed: Embed,
215+
attachment: LogInfo?,
216+
level: Logger.Level
217+
) {
218+
self.include(
219+
address: address,
220+
embed: embed,
221+
attachment: attachment,
222+
level: level,
223+
isFirstAliveNotice: false
224+
)
168225
}
169226

170227
private func include(
171228
address: WebhookAddress,
172229
embed: Embed,
230+
attachment: LogInfo?,
173231
level: Logger.Level?,
174232
isFirstAliveNotice: Bool
175233
) {
@@ -187,6 +245,7 @@ public actor DiscordLogManager {
187245

188246
self.logs[address]!.append(.init(
189247
embed: embed,
248+
attachment: attachment,
190249
level: level,
191250
isFirstAliveNotice: isFirstAliveNotice
192251
))
@@ -232,6 +291,7 @@ public actor DiscordLogManager {
232291
timestamp: Date(),
233292
color: config.color
234293
),
294+
attachment: nil,
235295
level: nil,
236296
isFirstAliveNotice: isFirstNotice
237297
)
@@ -295,21 +355,29 @@ public actor DiscordLogManager {
295355

296356
await sendLogsToWebhook(
297357
content: mentions,
298-
embeds: logs.map(\.embed),
358+
logs: logs.map({ ($0.embed, $0.attachment) }),
299359
address: address
300360
)
301361

302362
self.setUpAliveNotices()
303363
}
304-
364+
305365
private func sendLogsToWebhook(
306366
content: String,
307-
embeds: [Embed],
367+
logs: [(embed: Embed, attachment: LogInfo?)],
308368
address: WebhookAddress
309369
) async {
370+
let attachment = makeAttachmentData(attachments: logs.map(\.attachment))
371+
310372
let payload = Payloads.ExecuteWebhook(
311373
content: content,
312-
embeds: embeds
374+
embeds: logs.map(\.embed),
375+
files: attachment.map { (name, buffer) in
376+
[RawFile(data: buffer, filename: name)]
377+
},
378+
attachments: attachment.map { (name, _) in
379+
[.init(index: 0, filename: name)]
380+
}
313381
)
314382
do {
315383
try await self.client.executeWebhookWithResponse(
@@ -323,7 +391,33 @@ public actor DiscordLogManager {
323391
])
324392
}
325393
}
326-
394+
395+
private func makeAttachmentData(attachments: [LogInfo?]) -> (name: String, data: ByteBuffer)? {
396+
if let formatter = self.configuration.sendFullLogAsAttachment.formatter {
397+
let attachments: [LogContainer] = attachments
398+
.enumerated()
399+
.filter({ $0.element != nil })
400+
.map({ LogContainer(number: $0.offset + 1, info: $0.element!) })
401+
if attachments.isEmpty {
402+
return nil
403+
} else {
404+
var buffer = formatter.format(logs: attachments)
405+
/// Discord has a limit of 25MB of attachments.
406+
/// 24MB is already too much for 10 logs, so we just truncate the buffer.
407+
let mb24 = 24_000_000
408+
if buffer.readableBytes > mb24 {
409+
buffer = buffer.getSlice(at: buffer.readerIndex, length: mb24) ?? ByteBuffer(
410+
string: "<error-could-not-slice-buffer-please-report-on-github-in-DiscordLogger-repo>"
411+
)
412+
}
413+
let name = formatter.makeFilename(logs: attachments)
414+
return (name, buffer)
415+
}
416+
} else {
417+
return nil
418+
}
419+
}
420+
327421
private func logWarning(
328422
_ message: Logger.Message,
329423
metadata: Logger.Metadata? = nil,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import struct NIOCore.ByteBuffer
2+
#if canImport(Darwin)
3+
import Foundation
4+
#else
5+
@preconcurrency import Foundation
6+
#endif
7+
8+
public protocol LogFormatter: Sendable {
9+
func format(logs: [LogContainer]) -> ByteBuffer
10+
func makeFilename(logs: [LogContainer]) -> String
11+
}
12+
13+
extension LogFormatter where Self == JSONLogFormatter {
14+
/// Formats the log attachment as a json file.
15+
/// The filename won't have a `.json` extension and it contains a time in `gregorian` calendar in `UTC`.
16+
/// Use ``LogFormatter.json(withJSONExtension:calendar:timezone:)`` to customize the behavior.
17+
public static var json: JSONLogFormatter {
18+
.json()
19+
}
20+
21+
/// Formats the log attachment as a json file.
22+
/// - Parameters:
23+
/// - withJSONExtension: Whether or not to include the `.json` extension in the filename.
24+
/// Setting this to true might make the file look bad on Desktop in Discord. Defaults to `false`.
25+
/// - timezone: What timezone to use for the date in filenames. Defaults to `UTC`.
26+
public static func json(
27+
withJSONExtension: Bool = false,
28+
calendar: Calendar = .init(identifier: .gregorian),
29+
timezone: TimeZone = .init(identifier: "UTC")!
30+
) -> JSONLogFormatter {
31+
JSONLogFormatter(
32+
withJSONExtension: withJSONExtension,
33+
calendar: calendar,
34+
timezone: timezone
35+
)
36+
}
37+
}
38+
39+
public struct JSONLogFormatter: LogFormatter {
40+
41+
private struct LogsEncodingContainer: Encodable {
42+
let logs: [LogContainer]
43+
44+
init(_ logs: [LogContainer]) {
45+
self.logs = logs
46+
}
47+
48+
private enum CodingKeys: String, CodingKey {
49+
case _1 = "1"
50+
case _2 = "2"
51+
case _3 = "3"
52+
case _4 = "4"
53+
case _5 = "5"
54+
case _6 = "6"
55+
case _7 = "7"
56+
case _8 = "8"
57+
case _9 = "9"
58+
case _10 = "10"
59+
60+
init(int: Int) {
61+
switch int {
62+
case 1: self = ._1
63+
case 2: self = ._2
64+
case 3: self = ._3
65+
case 4: self = ._4
66+
case 5: self = ._5
67+
case 6: self = ._6
68+
case 7: self = ._7
69+
case 8: self = ._8
70+
case 9: self = ._9
71+
case 10: self = ._10
72+
default:
73+
fatalError("Unexpected number in 'LogsEncodingContainer'.")
74+
}
75+
}
76+
}
77+
78+
func encode(to encoder: any Encoder) throws {
79+
var container = encoder.container(keyedBy: CodingKeys.self)
80+
for log in logs {
81+
let key = CodingKeys(int: log.number)
82+
try container.encode(log.info, forKey: key)
83+
}
84+
}
85+
}
86+
87+
let withJSONExtension: Bool
88+
let calendar: Calendar
89+
let timezone: TimeZone
90+
91+
public func format(logs: [LogContainer]) -> ByteBuffer {
92+
let encodingContainer = LogsEncodingContainer(logs)
93+
let data = try? DiscordGlobalConfiguration.encoder.encode(encodingContainer)
94+
return ByteBuffer(data: data ?? Data())
95+
}
96+
97+
public func makeFilename(logs: [LogContainer]) -> String {
98+
let date = makeDateString()
99+
let prefix = "Logs_\(date)"
100+
101+
if withJSONExtension {
102+
return "\(prefix).json"
103+
} else {
104+
return prefix
105+
}
106+
}
107+
108+
func makeDateString() -> String {
109+
let comps = calendar.dateComponents(in: timezone, from: Date())
110+
111+
func doubleDigit(_ int: Int) -> String {
112+
let description = "\(int)"
113+
if description.count == 1 {
114+
return "0\(description)"
115+
} else {
116+
return description
117+
}
118+
}
119+
let year = comps.year ?? 0
120+
let month = doubleDigit(comps.month ?? 0)
121+
let day = doubleDigit(comps.day ?? 0)
122+
let hour = doubleDigit(comps.hour ?? 0)
123+
let minute = doubleDigit(comps.minute ?? 0)
124+
let second = doubleDigit(comps.second ?? 0)
125+
126+
let string = "\(year)-\(month)-\(day)_\(hour)-\(minute)-\(second)"
127+
128+
return string
129+
}
130+
}

Sources/DiscordLogger/LogInfo.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Logging
2+
3+
public struct LogInfo: Sendable, Codable {
4+
public let level: Logger.Level
5+
public let message: String
6+
public let metadata: [String: String]?
7+
8+
public init(level: Logger.Level, message: String, metadata: [String: String]) {
9+
self.level = level
10+
self.message = message
11+
self.metadata = metadata.isEmpty ? nil : metadata
12+
}
13+
}
14+
15+
public struct LogContainer: Sendable, Codable {
16+
public let number: Int
17+
public let info: LogInfo
18+
19+
public init(number: Int, info: LogInfo) {
20+
self.number = number
21+
self.info = info
22+
}
23+
}

0 commit comments

Comments
 (0)