Skip to content

Commit fac4ca3

Browse files
authored
Structured Logs: Buffering & flushing of logs (#5628)
1 parent 6ee4973 commit fac4ca3

File tree

14 files changed

+452
-42
lines changed

14 files changed

+452
-42
lines changed

CHANGELOG.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44

55
### Features
66

7-
- Add experimental support for capturing structured logs via `SentrySDK.logger` (#5532, #5593, #5639)
8-
- Add experimental support for capturing structured logs via `SentrySDK.logger` (#5532, #5593)
7+
- Add experimental support for capturing structured logs via `SentrySDK.logger` (#5532, #5593, #5639, #5628)
98

109
### Improvements
1110

Samples/iOS-SwiftUI/iOS-SwiftUI-UITests/LaunchUITests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class LaunchUITests: XCTestCase {
1515

1616
let transactionName = app.staticTexts["TRANSACTION_NAME"]
1717
let transactionId = app.staticTexts["TRANSACTION_ID"]
18-
if !transactionName.waitForExistence(timeout: 1) {
18+
if !transactionName.waitForExistence(timeout: 5) {
1919
XCTFail("Span operation label not found")
2020
}
2121

SentryTestUtils/TestClient.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ public class TestClient: SentryClient {
150150
flushInvocations.record(timeout)
151151
}
152152

153-
public var captureLogsDataInvocations = Invocations<Data>()
154-
public override func captureLogsData(_ data: Data) {
155-
captureLogsDataInvocations.record(data)
153+
public var captureLogsDataInvocations = Invocations<(data: Data, count: NSNumber)>()
154+
public override func captureLogsData(_ data: Data, with count: NSNumber) {
155+
captureLogsDataInvocations.record((data, count))
156156
}
157157
}

SentryTestUtils/TestSentryDispatchQueueWrapper.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ import Foundation
6262
dispatchAfterInvocations.invocations.last?.block()
6363
}
6464

65+
public var dispatchAfterWorkItemInvocations = Invocations<(interval: TimeInterval, workItem: DispatchWorkItem)>()
66+
public override func dispatch(after interval: TimeInterval, workItem: DispatchWorkItem) {
67+
dispatchAfterWorkItemInvocations.record((interval, workItem))
68+
if blockBeforeMainBlock() {
69+
if dispatchAfterExecutesBlock {
70+
workItem.perform()
71+
}
72+
}
73+
}
74+
75+
public func invokeLastDispatchAfterWorkItem() {
76+
dispatchAfterWorkItemInvocations.invocations.last?.workItem.perform()
77+
}
78+
6579
public var dispatchCancelInvocations = 0
6680
public override var shouldDispatchCancel: Bool {
6781
dispatchCancelInvocations += 1

Sources/Sentry/SentryClient.m

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,12 +1129,13 @@ - (void)removeAttachmentProcessor:(id<SentryClientAttachmentProcessor>)attachmen
11291129
return processedAttachments;
11301130
}
11311131

1132-
- (void)captureLogsData:(NSData *)data
1132+
- (void)captureLogsData:(NSData *)data with:(NSNumber *)itemCount;
11331133
{
11341134
SentryEnvelopeItemHeader *header =
11351135
[[SentryEnvelopeItemHeader alloc] initWithType:SentryEnvelopeItemTypeLog
11361136
length:data.length
1137-
contentType:@"application/vnd.sentry.items.log+json"];
1137+
contentType:@"application/vnd.sentry.items.log+json"
1138+
itemCount:itemCount];
11381139

11391140
SentryEnvelopeItem *envelopeItem = [[SentryEnvelopeItem alloc] initWithHeader:header data:data];
11401141
SentryEnvelope *envelope = [[SentryEnvelope alloc] initWithHeader:[SentryEnvelopeHeader empty]

Sources/Sentry/SentrySDK.m

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,19 @@ + (SentryReplayApi *)replay
118118

119119
+ (SentryLogger *)logger
120120
{
121+
121122
@synchronized(currentLoggerLock) {
122123
if (currentLogger == nil) {
124+
123125
SentryLogBatcher *batcher;
124126
if (nil != currentHub.client && currentHub.client.options.experimental.enableLogs) {
125-
batcher = [[SentryLogBatcher alloc] initWithClient:currentHub.client];
127+
batcher = [[SentryLogBatcher alloc]
128+
initWithClient:currentHub.client
129+
dispatchQueue:SentryDependencyContainer.sharedInstance.dispatchQueueWrapper];
126130
} else {
127131
batcher = nil;
128132
}
133+
129134
currentLogger = [[SentryLogger alloc]
130135
initWithHub:currentHub
131136
dateProvider:SentryDependencyContainer.sharedInstance.dateProvider

Sources/Sentry/include/SentryClient+Logs.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ NS_ASSUME_NONNULL_BEGIN
77
/**
88
* Helper to capture encoded logs, as SentryEnvelope can't be used in the Swift SDK.
99
*/
10-
- (void)captureLogsData:(NSData *)data;
10+
- (void)captureLogsData:(NSData *)data with:(NSNumber *)itemCount;
1111

1212
@end
1313

Sources/Swift/Helper/SentryDispatchQueueWrapper.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,16 @@
4545
public func dispatch(after interval: TimeInterval, block: @escaping () -> Void) {
4646
internalWrapper.dispatch(after: interval, block: block)
4747
}
48-
48+
4949
public func dispatchOnce(_ predicate: UnsafeMutablePointer<CLong>, block: @escaping () -> Void) {
5050
internalWrapper.dispatchOnce(predicate, block: block)
5151
}
5252

53+
@_spi(Private) public func dispatch(after interval: TimeInterval, workItem: DispatchWorkItem) {
54+
// Swift only API, so we need to call the internal queue directly.
55+
internalWrapper.queue.asyncAfter(deadline: .now() + interval, execute: workItem)
56+
}
57+
5358
// The ObjC version of this code wrapped `dispatch_cancel` and `dispatch_block_create`
5459
// However dispatch_block is not accessible in Swift. Unit tests rely on stubbing out
5560
// the creation and cancelation of dispatch blocks, so these two variables allow

Sources/Swift/Tools/SentryLogBatcher.swift

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,135 @@ import Foundation
66
@_spi(Private) public class SentryLogBatcher: NSObject {
77

88
private let client: SentryClient
9+
private let flushTimeout: TimeInterval
10+
private let maxBufferSizeBytes: Int
11+
private let dispatchQueue: SentryDispatchQueueWrapper
912

10-
let options: Options
13+
internal let options: Options
14+
15+
// All mutable state is accessed from the same serial dispatch queue.
1116

12-
@_spi(Private) public init(client: SentryClient) {
17+
// Every logs data is added sepratley. They are flushed together in an envelope.
18+
private var encodedLogs: [Data] = []
19+
private var encodedLogsSize: Int = 0
20+
private var timerWorkItem: DispatchWorkItem?
21+
22+
/// Initializes a new SentryLogBatcher.
23+
/// - Parameters:
24+
/// - client: The SentryClient to use for sending logs
25+
/// - flushTimeout: The timeout interval after which buffered logs will be flushed
26+
/// - maxBufferSizeBytes: The maximum buffer size in bytes before triggering an immediate flush
27+
/// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state
28+
///
29+
/// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety.
30+
/// Passing a concurrent queue will result in undefined behavior and potential data races.
31+
@_spi(Private) public init(
32+
client: SentryClient,
33+
flushTimeout: TimeInterval,
34+
maxBufferSizeBytes: Int,
35+
dispatchQueue: SentryDispatchQueueWrapper
36+
) {
1337
self.client = client
1438
self.options = client.options
39+
self.flushTimeout = flushTimeout
40+
self.maxBufferSizeBytes = maxBufferSizeBytes
41+
self.dispatchQueue = dispatchQueue
1542
super.init()
1643
}
1744

18-
func add(_ log: SentryLog) {
19-
dispatch(logs: [log])
45+
/// Convenience initializer with default flush timeout and buffer size.
46+
/// - Parameters:
47+
/// - client: The SentryClient to use for sending logs
48+
/// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state
49+
///
50+
/// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety.
51+
/// Passing a concurrent queue will result in undefined behavior and potential data races.
52+
@_spi(Private) public convenience init(client: SentryClient, dispatchQueue: SentryDispatchQueueWrapper) {
53+
self.init(
54+
client: client,
55+
flushTimeout: 5,
56+
maxBufferSizeBytes: 1_024 * 1_024, // 1MB
57+
dispatchQueue: dispatchQueue
58+
)
59+
}
60+
61+
@_spi(Private) func add(_ log: SentryLog) {
62+
dispatchQueue.dispatchAsync { [weak self] in
63+
self?.encodeAndBuffer(log: log)
64+
}
2065
}
2166

22-
private func dispatch(logs: [SentryLog]) {
67+
@objc
68+
@_spi(Private) func flush() {
69+
dispatchQueue.dispatchAsync { [weak self] in
70+
self?.performFlush()
71+
}
72+
}
73+
74+
// Helper
75+
76+
// Only ever call this from the serial dispatch queue.
77+
private func encodeAndBuffer(log: SentryLog) {
2378
do {
24-
let payload = ["items": logs]
25-
let data = try encodeToJSONData(data: payload)
79+
let encodedLog = try encodeToJSONData(data: log)
80+
81+
let encodedLogsWereEmpty = encodedLogs.isEmpty
2682

27-
client.captureLogsData(data)
83+
encodedLogs.append(encodedLog)
84+
encodedLogsSize += encodedLog.count
85+
86+
if encodedLogsSize >= maxBufferSizeBytes {
87+
performFlush()
88+
} else if encodedLogsWereEmpty && timerWorkItem == nil {
89+
startTimer()
90+
}
2891
} catch {
29-
SentrySDKLog.error("Failed to create logs envelope.")
92+
SentrySDKLog.error("Failed to encode log: \(error)")
93+
}
94+
}
95+
96+
// Only ever call this from the serial dispatch queue.
97+
private func startTimer() {
98+
let timerWorkItem = DispatchWorkItem { [weak self] in
99+
SentrySDKLog.debug("SentryLogBatcher: Timer fired, calling performFlush().")
100+
self?.performFlush()
101+
}
102+
self.timerWorkItem = timerWorkItem
103+
dispatchQueue.dispatch(after: flushTimeout, workItem: timerWorkItem)
104+
}
105+
106+
// Only ever call this from the serial dispatch queue.
107+
private func performFlush() {
108+
// Reset logs on function exit
109+
defer {
110+
encodedLogs.removeAll()
111+
encodedLogsSize = 0
112+
}
113+
114+
// Reset timer state
115+
timerWorkItem?.cancel()
116+
timerWorkItem = nil
117+
118+
guard encodedLogs.count > 0 else {
119+
SentrySDKLog.debug("SentryLogBatcher: No logs to flush.")
120+
return
121+
}
122+
123+
// Create the payload.
124+
125+
var payloadData = Data()
126+
payloadData.append(Data("{\"items\":[".utf8))
127+
let separator = Data(",".utf8)
128+
for (index, encodedLog) in encodedLogs.enumerated() {
129+
if index > 0 {
130+
payloadData.append(separator)
131+
}
132+
payloadData.append(encodedLog)
30133
}
134+
payloadData.append(Data("]}".utf8))
135+
136+
// Send the payload.
137+
138+
client.captureLogsData(payloadData, with: NSNumber(value: encodedLogs.count))
31139
}
32140
}

Tests/Perf/metrics-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ startupTimeTest:
1111

1212
binarySizeTest:
1313
diffMin: 200 KiB
14-
diffMax: 875 KiB
14+
diffMax: 880 KiB

0 commit comments

Comments
 (0)