Building a custom logger: Custom Swift String interpolation for all types

Viewed 278

I'd like to reproduce the behavior of the new Swift logging features where interpolated values in a String show as <private> instead of their actual value for a custom logger that I use in my app.

For example:

let accountNumber = 12345
log("User account number: \(accountNumber)")

// User account number: 12345  ← leaking personal information 

Instead I'd like this result:

// User account number: <private>

I looked at extending String.StringInterpolation and it works great for custom types, but I could not figure out how to hook into the default interpolation, since I need to catch ANY interpolated type.

Here is an example with a custom argument name, but it's too easy to forget to specify that:

extension String.StringInterpolation
{
    mutating func appendInterpolation<T>(private value:T) {
        let literal = "<private>"
        appendLiteral(literal)
    }
}

log("User account number: \(private: accountNumber)") // Too easy to forget "private:"...

// User account number: <private> 
1 Answers

You can create a custom type - let's call it SecureMessage - that is ExpressibleByStringInterpolation (and CustomStringConvertible for good measure) and have your log function take that as its message parameter:

func log(_ message: SecureMessage) {
   print("\(message)")
}

let accountNumber = 12345
log("User account number: \(accountNumber)")

Now, we can define SecureMessage:

struct SecureMessage: ExpressibleByStringInterpolation, 
                      CustomStringConvertible {

   struct StringInterpolation: StringInterpolationProtocol {
      var output = ""

      init(literalCapacity: Int, interpolationCount: Int) { }
        
      mutating func appendLiteral(_ literal: String) {
         output.append(literal)
      }

      mutating func appendInterpolation<T>(_ str: T) {
         output.append("<private>")
      }
   }

   let description: String

   init(stringLiteral value: String) {
      description = value
   }

   init(stringInterpolation: StringInterpolation) {
      description = stringInterpolation.output
   }
}

Of course, this is kinda useless, since it will hide all interpolations, not just what you consider private ones.

So, you can probably define a privacy level so you apply it per-interpolation:

enum PrivacyLevel {
   case `public`, `private`
}

and change appendInterpolation method to take a privacy param with some default privacy value:

mutating func appendInterpolation<T: LosslessStringConvertible>(_ str: T, privacy: PrivacyLevel = .private) {
   switch privacy {
      case .private:
         output.append("<private>")
      case .public:
         output.append(String(str))
   }
}
log("Hidden account number:  \(accountNumber)") // private by default
log("Visible account number: \(accountNumber, privacy: .public)")
Related