Classes/TLSRollingFileOutputStream.m (390 lines of code) (raw):

// // TLSRollingFileOutputStream.m // TwitterLoggingService // // Created by Kirk Beitz on 06/07/13. // Copyright (c) 2016 Twitter, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #import "TLS_Project.h" #import "TLSLoggingService+Advanced.h" #import "TLSRollingFileOutputStream.h" NSString * const TLSRollingFileOutputStreamDefaultLogFilePrefix = @"log."; const NSUInteger TLSRollingFileOutputStreamDefaultMaxBytesPerLogFile = (1024 * 256); const NSUInteger TLSRollingFileOutputStreamDefaultMaxLogFiles = 10; static NSString * const TLSRollingFileOutputStreamDefaultLogFileExtension = @"log"; static NSString * const TLSRollingFileOutputEventKeyNewLogFilePath = @"newLogFilePath"; static NSString * const TLSRollingFileOutputEventKeyOldLogFilePath = @"oldLogFilePath"; static NSString * const TLSRollingFileOutputEventKeyLogData = @"logData"; // Mar 21, 2006 @ 12:50PM - aka. "just setting up my twttr" static const time_t kTLSReferenceTime = 164667000; static const NSUInteger kMinLogFiles = 1; static const NSUInteger kMaxLogFiles = 1024; static const NSUInteger kMinBytesPerFile = 1024; // 1 KB static const NSUInteger kMaxBytesPerFile = 1 * 1024 * 1024 * 1024; // 1 GB static const unsigned long long kMaxBytesTotal = 4ULL * 1024ULL * 1024ULL * 1024ULL; // 4 GB #define LOG_EVENT_PREFIX @"[LOG EVENT] : " typedef long long TLSLogFileId; static TLSLogFileId _GenerateLogFileId(void); static TLSLogFileId _GenerateLogFileId() { static NSDate *sReferenceDate; static dispatch_once_t sOnceToken; dispatch_once(&sOnceToken, ^{ sReferenceDate = [NSDate dateWithTimeIntervalSinceReferenceDate:kTLSReferenceTime]; }); NSTimeInterval ti = [[NSDate date] timeIntervalSinceDate:sReferenceDate]; ti *= 100.0f; return (TLSLogFileId)ti; } static NSString *_GenerateLogFileName(NSString* __nonnull prefix, TLSLogFileId fileId); static NSString *_GenerateLogFileName(NSString* prefix, TLSLogFileId fileId) { return [NSString stringWithFormat:@"%@%qu.%@", prefix, fileId, TLSRollingFileOutputStreamDefaultLogFileExtension]; } TLS_OBJC_DIRECT_MEMBERS @interface TLSRollingFileOutputStream (Private) - (BOOL)_rolloverIfNeeded; - (BOOL)_purgeOldLogsIfNeeded; - (nullable NSArray<NSString *> *)_getLogFiles; - (void)_writeStartupTimestampInfo; @end @implementation TLSRollingFileOutputStream { BOOL _hasRunPrune; } - (instancetype)initWithOutError:(NSError **)errorOut { return [self initAndReturnError:errorOut]; } - (instancetype)initAndReturnError:(NSError **)errorOut { return [self initWithLogFileDirectoryPath:nil error:errorOut]; } - (instancetype)initWithLogFileDirectoryPath:(NSString *)logFileDirectoryPath { return [self initWithLogFileDirectoryPath:logFileDirectoryPath error:NULL]; } - (instancetype)initWithLogFileDirectoryPath:(NSString *)logFileDirectoryPath error:(out NSError **)errorOut { return [self initWithLogFileDirectoryPath:logFileDirectoryPath logFilePrefix:TLSRollingFileOutputStreamDefaultLogFileExtension maxLogFiles:TLSRollingFileOutputStreamDefaultMaxLogFiles maxBytesPerLogFile:TLSRollingFileOutputStreamDefaultMaxBytesPerLogFile error:errorOut]; } - (instancetype)initWithLogFileDirectoryPath:(NSString *)logFileDirectoryPath logFilePrefix:(NSString *)logFilePrefix maxLogFiles:(NSUInteger)maxLogFiles maxBytesPerLogFile:(NSUInteger)maxBytesPerLogFile { return [self initWithLogFileDirectoryPath:logFileDirectoryPath logFilePrefix:logFilePrefix maxLogFiles:maxLogFiles maxBytesPerLogFile:maxBytesPerLogFile error:NULL]; } - (instancetype)initWithLogFileDirectoryPath:(NSString *)logFileDirectoryPath logFilePrefix:(NSString *)logFilePrefix maxLogFiles:(NSUInteger)maxLogFiles maxBytesPerLogFile:(NSUInteger)maxBytesPerLogFile error:(out NSError **)errorOut // NS_DESIGNATED_INIIALIZER { // allocation preconditions if (errorOut) { *errorOut = nil; } if (!logFileDirectoryPath) { logFileDirectoryPath = [TLSFileOutputStream defaultLogFileDirectoryPath]; } if (!logFilePrefix) { logFilePrefix = @""; } // generate a log-file name, including edge-case of a duplicate file TLSLogFileId fileId = _GenerateLogFileId(); NSString* generatedLogFileName; NSFileManager* fm = [NSFileManager defaultManager]; while ([fm fileExistsAtPath:[logFileDirectoryPath stringByAppendingPathComponent:(generatedLogFileName = _GenerateLogFileName(logFilePrefix, fileId))]]) { ++fileId; } NSError *error = nil; self = [super initWithLogFileDirectoryPath:logFileDirectoryPath logFileName:generatedLogFileName error:&error]; if (self) { [self tls_fileOutputEventBegan:TLSRollingFileOutputEventInitialize info:nil]; maxBytesPerLogFile = MAX(MIN(maxBytesPerLogFile, kMaxBytesPerFile), kMinBytesPerFile); NSUInteger absoluteMaxLogFiles = (NSUInteger)MIN((kMaxBytesTotal / (unsigned long long)maxBytesPerLogFile), (unsigned long long)kMaxLogFiles); maxLogFiles = MAX(MIN(maxLogFiles, absoluteMaxLogFiles), kMinLogFiles); _maxBytesPerLogFile = maxBytesPerLogFile; _maxLogFiles = maxLogFiles; _logFilePrefix = [logFilePrefix copy]; _hasRunPrune = NO; [self tls_fileOutputEventFinished:TLSRollingFileOutputEventInitialize info:@{ TLSRollingFileOutputEventKeyNewLogFilePath : _logFilePath }]; } else if (!error) { NSString *exceptionName = NSDestinationInvalidException; NSString *message = [NSString stringWithFormat:@"'%@' could not be created in destination directory %@!", generatedLogFileName, logFileDirectoryPath]; NSDictionary *info = @{ @"logFileDirectoryPath" : (logFileDirectoryPath) ?: [NSNull null], @"generatedLogFileName" : generatedLogFileName, @"message" : message, @"exceptionName" : exceptionName }; error = [NSError errorWithDomain:TLSErrorDomain code:EINVAL userInfo:info]; } if (error) { [self tls_fileOutputEventFailed:TLSRollingFileOutputEventInitialize info:error.userInfo error:error]; if (errorOut) { *errorOut = error; } return nil; } return self; } #pragma mark - TLSFileOutputStream override implementations - (void)outputLogData:(NSData *)data { NSDictionary *info = @{ TLSRollingFileOutputEventKeyLogData : (data) ?: [NSNull null] }; [self tls_fileOutputEventBegan:TLSRollingFileOutputEventOutputLogData info:info]; // The only way `data` can be `nil` is if the caller coersed it to be a `nonnull` argument. // Since we are just wrapping the behavior of `outputLogData:` we MUST NOT change its behavior // and need to pass the `data` argument in the same coersed fashion so that the base // implementation can maintain ownership of acting upon `data`. [super outputLogData:(NSData * __nonnull)data]; [self tls_fileOutputEventFinished:TLSRollingFileOutputEventOutputLogData info:info]; if ([self _rolloverIfNeeded] || !_hasRunPrune) { [self _purgeOldLogsIfNeeded]; } } #pragma mark - TLSDataRetrieval protocol implementations - (NSData *)tls_retrieveLoggedData:(NSUInteger)maxBytes { if (maxBytes <= self.maxBytesPerLogFile) { maxBytes = self.maxBytesPerLogFile; } [self tls_flush]; NSArray<NSString *> *logs = [self _getLogFiles]; NSString *logDirectoryPath = self.logFileDirectoryPath; NSMutableData *data = nil; NSFileManager *fm = [NSFileManager defaultManager]; if (logs.count > 0) { // go through the logs, newest to oldest // prepend 1 log file at a time so long as it doesn't exceed our maximum size restrictions for (NSInteger i = (logs.count - 1); i >= 0; i--) { // keep this loop tight with an autorelease pool @autoreleasepool { NSString* logPath = [logs objectAtIndex:i]; logPath = [logDirectoryPath stringByAppendingPathComponent:logPath]; if (!data || ((data.length + [fm attributesOfItemAtPath:logPath error:NULL].fileSize) <= maxBytes)) { NSMutableData* fileData = [NSMutableData dataWithContentsOfFile:logPath]; if (data) { [fileData appendData:data]; } data = fileData; } } } } return data; // don't copy the gobs of data we just created... } #pragma mark - TLSFileOutputStreamEvent protocol implementation - (void)tls_fileOutputEventBegan:(TLSFileOutputEvent)event info:(NSDictionary *)info { if (TLSRollingFileOutputEventRolloverLogs == event) { NSString *newFile = info[TLSRollingFileOutputEventKeyNewLogFilePath]; [self writeString:LOG_EVENT_PREFIX @"Single log limit reached. Moving to "]; [self writeString:newFile]; [self writeNewline]; } else if (TLSRollingFileOutputEventPruneLogs == event) { [self writeString:[NSString stringWithFormat:LOG_EVENT_PREFIX @"At log file limit of %tu. Pruning log files.", self.maxLogFiles]]; [self writeNewline]; } } - (void)tls_fileOutputEventFinished:(TLSFileOutputEvent)event info:(NSDictionary *)info { if (TLSRollingFileOutputEventInitialize == event) { [self _writeStartupTimestampInfo]; } else if (TLSRollingFileOutputEventRolloverLogs == event) { NSString *oldFile = info[TLSRollingFileOutputEventKeyOldLogFilePath]; [self writeString:LOG_EVENT_PREFIX @"... continuing log from "]; [self writeString:oldFile]; [self writeNewline]; [self _writeStartupTimestampInfo]; } else if (TLSRollingFileOutputEventPruneLogs == event) { [self writeString:LOG_EVENT_PREFIX @"logs successfully pruned."]; [self writeNewline]; } else if (TLSRollingFileOutputEventPurgeLog == event) { NSString *oldFile = info[TLSRollingFileOutputEventKeyOldLogFilePath]; [self writeString:LOG_EVENT_PREFIX @"Purged old log file: "]; [self writeString:oldFile]; [self writeNewline]; } } - (void)tls_fileOutputEventFailed:(TLSFileOutputEvent)event info:(NSDictionary *)info error:(NSError *)error { NSString *message = error.userInfo[@"message"]; [self writeString:LOG_EVENT_PREFIX @"ERROR - "]; [self writeString:message]; [self writeNewline]; if (TLSRollingFileOutputEventRolloverLogs == event) { NSString *newFile = info[TLSRollingFileOutputEventKeyNewLogFilePath]; [self writeString:LOG_EVENT_PREFIX @"ERROR - could not open "]; [self writeString:newFile]; [self writeNewline]; } else if (TLSRollingFileOutputEventPurgeLog == event) { NSString *oldFile = info[TLSRollingFileOutputEventKeyOldLogFilePath]; [self writeString:LOG_EVENT_PREFIX @"ERROR - failed to purge old log file: "]; [self writeString:oldFile]; [self writeNewline]; } } @end #pragma mark - private method implementations @implementation TLSRollingFileOutputStream (Private) - (BOOL)_rolloverIfNeeded { BOOL didRollover = NO; if (_maxBytesPerLogFile < self.bytesWritten) { #if DEBUG NSCAssert(_maxBytesPerLogFile > 0, @"Max bytes per file must not be 0"); #endif NSFileManager* fm = [NSFileManager defaultManager]; NSString *oldFilePath = self.logFilePath; NSString *oldFileDir = self.logFileDirectoryPath; TLSLogFileId fileId = _GenerateLogFileId(); NSString *newFilePath = [oldFileDir stringByAppendingPathComponent:_GenerateLogFileName(self.logFilePrefix, fileId)]; // fileId is based on the current second, here's code to handle super edge case of resusing the same file id. while ([fm fileExistsAtPath:newFilePath]) { fileId++; newFilePath = [oldFileDir stringByAppendingPathComponent:_GenerateLogFileName(self.logFilePrefix, fileId)]; } #if DEBUG NSCAssert([oldFileDir isEqualToString:[oldFilePath stringByDeletingLastPathComponent]], @"Path missmatch!"); NSCAssert(![newFilePath isEqualToString:oldFilePath], @"Old path cannot match new path!"); #endif NSDictionary *eventInfo = @{ TLSRollingFileOutputEventKeyOldLogFilePath : oldFilePath, TLSRollingFileOutputEventKeyNewLogFilePath : newFilePath }; [self tls_fileOutputEventBegan:TLSRollingFileOutputEventRolloverLogs info:eventInfo]; NSError *openError; if (!(didRollover = [self openLogFilePath:newFilePath error:&openError])) { [self tls_fileOutputEventFailed:TLSRollingFileOutputEventRolloverLogs info:eventInfo error:[NSError errorWithDomain:NSDestinationInvalidException code:errno userInfo:@{ @"message" : @"Log could not be rolled over" }]]; } else { [self tls_fileOutputEventFinished:TLSRollingFileOutputEventRolloverLogs info:eventInfo]; } } return didRollover; } - (BOOL)_purgeOldLogsIfNeeded { _hasRunPrune = YES; NSArray<NSString *>* logs = [self _getLogFiles]; BOOL purgeMade = NO; NSUInteger logFileCount = logs.count; NSUInteger maxLogFiles = self.maxLogFiles; if (logFileCount > maxLogFiles) { #if DEBUG NSCAssert(maxLogFiles > 0, @"Must have maximum number of log files be at least 1"); #endif NSString *root = self.logFileDirectoryPath; NSString *currentFile = self.logFilePath; [self tls_fileOutputEventBegan:TLSRollingFileOutputEventPruneLogs info:nil]; NSFileManager *fm = [NSFileManager defaultManager]; NSUInteger filesToDelete = logFileCount - maxLogFiles; for (NSUInteger i = 0; i < logFileCount && 0 != filesToDelete; i++) { NSString* nextLog = [root stringByAppendingPathComponent:[logs objectAtIndex:i]]; if ([nextLog isEqualToString:currentFile]) { // Ran out of logs to purge break; } NSError *err = nil; NSDictionary *eventInfo = @{ TLSRollingFileOutputEventKeyOldLogFilePath : nextLog }; [self tls_fileOutputEventBegan:TLSRollingFileOutputEventPurgeLog info:eventInfo]; if ([fm removeItemAtPath:nextLog error:&err]) { [self tls_fileOutputEventFinished:TLSRollingFileOutputEventPurgeLog info:eventInfo]; filesToDelete--; purgeMade = YES; } else { NSString *domain = NSDestinationInvalidException; NSDictionary *errInfo = @{ @"message" : @"File could not be purged" }; NSInteger code = EPERM; if (err) { domain = err.domain; code = err.code; NSMutableDictionary *errInfoM = [err.userInfo mutableCopy]; if (!errInfoM) { errInfoM = [[NSMutableDictionary alloc] init]; } if (!errInfoM[@"message"]) { errInfoM[@"message"] = errInfo[@"message"]; } errInfo = errInfoM; } [self tls_fileOutputEventFailed:TLSRollingFileOutputEventPurgeLog info:eventInfo error:[NSError errorWithDomain:domain code:code userInfo:errInfo]]; } } if (filesToDelete > 0) { [self tls_fileOutputEventFailed:TLSRollingFileOutputEventPruneLogs info:nil error:[NSError errorWithDomain:NSGenericException code:EIO userInfo:@{ @"message" : @"Could not prune enough logs to reach out log file limit" }]]; } else { [self tls_fileOutputEventFinished:TLSRollingFileOutputEventPruneLogs info:nil]; } } return purgeMade; } - (nullable NSArray<NSString *> *)_getLogFiles { NSError *err = nil; NSMutableArray<NSString *> *logs = [[[NSFileManager defaultManager] contentsOfDirectoryAtPath:self.logFileDirectoryPath error:&err] mutableCopy]; if (err) { // should this be a TLSFileOutputEvent so we can log it? return nil; } @autoreleasepool { NSIndexSet *indexSet = [logs indexesOfObjectsPassingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop) { return ![(NSString *)obj hasPrefix:self->_logFilePrefix] || ![[(NSString *)obj pathExtension] isEqualToString:TLSRollingFileOutputStreamDefaultLogFileExtension]; }]; [logs removeObjectsAtIndexes:indexSet]; NSUInteger prefixLength = _logFilePrefix.length; [logs sortUsingComparator:^NSComparisonResult (id obj1, id obj2) { NSString* logFile1 = [obj1 stringByDeletingPathExtension]; NSString* logFile2 = [obj2 stringByDeletingPathExtension]; if (logFile1.length <= prefixLength || logFile2.length <= prefixLength) { return [logFile1 compare:logFile2]; } logFile1 = [logFile1 substringFromIndex:prefixLength]; logFile2 = [logFile2 substringFromIndex:prefixLength]; long long stamp1 = logFile1.longLongValue; long long stamp2 = logFile2.longLongValue; if (stamp1 < stamp2) { return NSOrderedAscending; } else if (stamp1 > stamp2) { return NSOrderedDescending; } return NSOrderedSame; }]; } return [logs copy]; } - (void)_writeStartupTimestampInfo { static NSDateFormatter *sFormatter = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sFormatter = [[NSDateFormatter alloc] init]; sFormatter.dateFormat = @"YYYY'-'MM'-'dd HH':'mm':'ss zZ"; sFormatter.timeZone = [NSTimeZone localTimeZone]; }); NSString *startupTimestamp = [sFormatter stringFromDate:[TLSLoggingService sharedInstance].startupTimestamp]; [self writeString:LOG_EVENT_PREFIX]; [self writeString:NSStringFromClass([TLSLoggingService class])]; [self writeString:@" startup = '"]; [self writeString:startupTimestamp]; [self writeString:@"'"]; [self writeNewline]; } @end