Swift-loggable - a macro for logging functions

Hi,

Over the past month, I have been playing with BodyMacro, which we gained access to with the release of Swift 6. I really enjoyed it, so as a side project, I created an MVP that I would like to share with you now.

First thing that came to my mind when I read the proposal and played with it was a logging library. However, I didn’t want to limit anyone to my logging of choice, so I wanted the design to allow anyone to incorporate logic of their choice, be it Sentry, swift-log or any other library. By default it uses os_log.

swift-loggable comes with three macros (technically), but the third one is just meant to ignore logging for a function :)

@Logged is a MemberAttributeMacro. It can be attached, for example, to a class, and it will provide every function with a @Log annotation - which I will cover briefly in a second. If we don’t want a function logged, the third macro comes into play - @Omit, which prevents the marked function from being annotated with @Log. Both @Logged and @Log can take an argument of type Loggable. Loggable is a class that we can inherit from and override its functions to implement our own logic. I’m not yet sure if this design choice was the best, but it allowed for a nicer syntax; I covered a little bit more in the README. When a custom Loggable is passed to @Logged e.g., @Logged(using: .custom), it will propagate our custom logic implementation to the functions within its scope.

@Log, the heart of swift-loggable, is our BodyMacro. It was a bit tricky to come up with a way to log a function because there are many possible combinations of function implementations one can come up with. Under the hood, a copy of the original function declaration is made, which allowed me to capture thrown errors or values returned by the function. As of now, it supports static, throwing, async, and generic functions with inout parameters, parameters preceded by the @autoclosure attribute and, of course, regular ones.


Usage examples

Logging with swift-loggable is a simple as annoting type with @Logged, it will automatically add @Log annotation to every function inside.

@Logged
struct Foo { 
  // ...
}

If you don’t want to log a function that is located in a scope annotated with @Logged, mark it with @Omit, it will be ignored when the macro is expanded.

@Logged
struct Foo { 
  func bar() { 
    // ...
  }

  @Omit
  func baz() {
    // ... 
  }
}

When you have to log only specific functions, use the @Log macro, e.g.:

struct Foo { 
  @Log
  func bar() { 
    // ...
  }
}

As mentioned earlier, you can pass custom logging logic to both the @Logged and @Log macros. While the README covers this topic in detail, it essentially works as follows:

@Logged(using: .custom)
struct Foo { 
  func bar() { 
    // ...
  }
}

Thank you for taking the time to read this. I’m looking forward to hearing your thoughts on this MVP.

3 Likes

Cool! But I have a question, I found that all the side effects are ignored in the test code. Is this intentional or a bug?

For example:

@Log
mutating func foo() {
    self.counter += 1
}

is expanded to:

mutating func foo() {
    Loggable.default.log(at: "TestModule/Test.swift:1:1", of: "mutating func foo()")
}

I see, it's not intentional, it’s a bug on my end.BodyMacros aren’t just additive they can also remove things. As I checked this issue will affects all functions whose signatures lacks a return clause.

Thanks for catching that, I have just fixed it.

1 Like

I haven’t look deep into it, please provide a few examples. Are you logging params/return value?

Consider making an indentation on the left to visualise function calls nesting (although that is tricky to do with async and tasks).

Hey, thanks for suggestion with examples, i forgot about it, i have updated my post to provide simple example as well as README which covers it more in depth. Answering your first question, yes it does log values returned by function as well as errors thrown from within it. When it comes for logging parameters, it is currently missing such functionality - i have mentioned in future directions section, when it comes for logging most parameters it wouldn't be a problem but one can come up with such parameter _ completion: () async throws -> Data which im not yet sure how it should be handled.

Regarding the second part, I’m not entirely sure if I understood it correctly. Are you referring to something like this?

// Logs from func foo
	// Logs from func bar which was called inside foo

For callback parameters perhaps just say "callback" or "function" or show some numeric address. In the example below I had to use @escaping, hopefully with macros there will be no such limitation.

Yes. Like in this example, but hidden behind the @Log macros

var level = 0 // TODO: task-local variable
func indentation(_ level: Int) -> String {
    String(repeating: "    ", count: level)
}
func baz() {
    print("\(Date()) \(indentation(level)) \(#function) entered")
    // ...
    print("\(Date()) \(indentation(level)) \(#function) exited")
}
func bar() {
    print("\(Date()) \(indentation(level)) \(#function) entered")
    level += 1
    baz()
    level -= 1
    print("\(Date()) \(indentation(level)) \(#function) exited")
}
func foo(x: Int, callback: @escaping () async throws -> Data) -> String {
    print("\(Date()) \(indentation(level)) \(#function)(\(x), \(callback)) entered")
    level += 1
    bar()
    level -= 1
    let result = "goodbye"
    print("\(Date()) \(indentation(level)) \(#function) exited with \(result)")
    return result
}
foo(x: 42) { print("xxx");  return Data() }
2025-02-12 18:31:35 +0000  foo(x:callback:)(42, (Function)) entered
2025-02-12 18:31:35 +0000      bar() entered
2025-02-12 18:31:35 +0000          baz() entered
2025-02-12 18:31:35 +0000          baz() exited
2025-02-12 18:31:35 +0000      bar() exited
2025-02-12 18:31:35 +0000  foo(x:callback:) exited with goodbye

I will try to address parameter logging this weekend. However, when it comes to indentation, I don’t believe I can modify the function signature to which the macro is attached also there isn’t a way for me to detect that a nested function has been marked with @Log so it can indented with its logs grouped with the calling function. That said, I don’t consider it impossible. I might need to track called functions in the Loggable class and connect them somehow. I’ll check how logging macros in other languages tackle this problem — it surely would be a valuable addition.

I don't think changing function signature is required... obviously using a global variable is bad, but something like task local could do.

Even if that's not an indentation per se, but open / closing brackets for entry / leave log lines - then some simple post processing (a-la json formatter) could be used to auto-indent as per brackets.

Forgot to mention (maybe you are already doing so?) ideally there must be a "master switch" to enable/disable this, and when disabled ideally there should be no extra code injected (increased compilation time is tolerable).

Great project, thank you.