Never work with children, animals or time: nailing the nanosecond
Quite unintentionally, last week became a saga about time, and a good demonstration of how you should do your utmost to avoid working with it. This all started with an irksome problem in my new log browser LogUI.
LogUI departs from my previous log browsers in accessing log entries through the macOS API added in Catalina. When it first arrived, I found it opaque, and its documentation too incomplete to support coding a competitive browser at that time. Since then I have revisited this on several occasions, and each time retreated to using the log show
command to obtain log extracts. As far as time is concerned, that presents me with two alternatives: a formatted string containing a date and timestamp down to the microsecond (10^-6 second), and a Mach timestamp giving the ‘ticks’ from an arbitrary start time.
Although the latter can only give relative time, the timestamp provided suffices for most purposes, and comes already formatted as2025-05-25 07:51:05.200099+0100
for example.
Milliseconds
When you use the macOS API, date and time don’t come formatted, but are supplied as a Date, an opaque structure that can be formatted using a DateFormatterlet dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
let dateString = dateFormatter.string(from: date)
to provide2025-05-24 13:46:12.683+0100
That only gives time down to the millisecond (10^-3 second), which is inadequate for many purposes. But changing the formatting string to "yyyy-MM-dd HH:mm:ss.SSSSSSZ"
just returns2025-05-24 13:46:12.683000+0100
with zeroed microseconds. As I can’t find any documentation that states the expected time resolution of Dates, it’s unclear whether this is a bug or feature, but either way a different approach is needed to resolve time beyond milliseconds.
Nanoseconds
The only method I can see to recover higher precision in the macOS API is using DateComponents to provide nanoseconds (10^-9 second).Calendar.current.component(.nanosecond, from: date)
returns an integer ready to format into a string usingnanosecStr = String(format: "%09d", nanoSeconds)
to give all nine digits.
Inserting that into a formatted date is a quick and simple way to construct what we want,2025-05-24 13:46:12.683746842+0100
down to the nanosecond.
It’s only when you come to examine more closely whether the numbers returned as nanoseconds match changes seen in Mach timestamp, that you realise they’re a fiction, and what’s given as nanoseconds is in fact microseconds with numerical decoration.
Microseconds
The answer then is to round what’s given as nanoseconds to the nearest microsecond, which then matches what’s shown in Mach timestampslet nanoSeconds = Int((Double(Calendar.current.component(.nanosecond, from: date))/1000.0).rounded())
and that can be converted into a string usingnanosecStr = String(format: "%06d", nanoSeconds)
Rather than manually format the rest of the date and timestamp, you can splice microseconds into the @ position of the format string"yyyy-MM-dd HH:mm:ss.@Z"
turning2025-05-24 13:46:12.@+0100
into2025-05-24 13:46:12.683747+0100
Unfortunately, that’s too simple. If you test that method using times, you’ll discover disconcerting anomalies arising from the fact that seconds and microseconds are rounded differently. This is reflected in a sequence such as2025-05-24 13:46:12.994142+0100
2025-05-24 13:46:13.999865+0100
2025-05-24 13:46:13.000183+0100
where the second rounds up to the next second even though the microseconds component hasn’t yet rounded up. The only way to address that is to format all the individual components in the string using DateComponents. And that leaves a further problem: how to get the time zone in standard format like +0100?
Time zones
Current time zone is available as an opaque TimeZone structure, obtained asTimeZone.current
Note that this doesn’t need to be obtained individually for each Date, as its components are obtained using current Calendar settings, not those at the time the Date was set in that log entry. This should have the beneficial side-effect of unifying times to the same time zone and DST setting.
But that doesn’t offer it in a format like +0100, so that has to be calculated and formatted aslet timeZone = (TimeZone.current.secondsFromGMT())/36
let tzStr = String(format: "%+05d", timeZone)
Solution
The complete solution is thus:let timeZone = (TimeZone.current.secondsFromGMT())/36
let tzStr = String(format: "%+05d", timeZone)
let dateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond, from: date)
let yearStr = String(format: "%04d", dateComponents.year ?? 0)
let monthStr = String(format: "%02d", dateComponents.month ?? 0)
let dayStr = String(format: "%02d", dateComponents.day ?? 0)
let hourStr = String(format: "%02d", dateComponents.hour ?? 0)
let minuteStr = String(format: "%02d", dateComponents.minute ?? 0)
let secondStr = String(format: "%02d", dateComponents.second ?? 0)
let nanoSeconds = Int((Double(dateComponents.nanosecond ?? 0)/1000.0).rounded())
let nanosecStr = String(format: "%06d", nanoSeconds)
then concatenate those together with punctuation marks as separators to deliver the string2025-05-24 13:46:12.683746+0100
If you’re coding in Swift, you might instead consider using Date.FormatStyle, although its documentation only refers to handling milliseconds, so I suspect that might turn out to be another wild goose chase.
If you know of a better way of handling this explicitly, don’t hesitate to let my code therapist know.