Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ let products: [Product] = [
name: "GRPCReflectionService",
targets: ["GRPCReflectionService"]
),
.library(
name: "GRPCOTelMetricsInterceptors",
targets: ["GRPCOTelMetricsInterceptors"]
),
.library(
name: "GRPCOTelTracingInterceptors",
targets: ["GRPCOTelTracingInterceptors"]
Expand Down Expand Up @@ -53,6 +57,10 @@ let dependencies: [Package.Dependency] = [
url: "https://github.com/apple/swift-protobuf.git",
from: "1.31.0"
),
.package(
url: "https://github.com/apple/swift-metrics.git",
from: "2.7.1"
),
.package(
url: "https://github.com/apple/swift-distributed-tracing.git",
from: "1.3.0"
Expand Down Expand Up @@ -131,10 +139,48 @@ let targets: [Target] = [
swiftSettings: defaultSwiftSettings
),

// gRPC interceptors shared infrastructure.
.target(
name: "GRPCInterceptorsCore",
dependencies: [
.product(name: "GRPCCore", package: "grpc-swift-2"),
],
swiftSettings: defaultSwiftSettings
),
.testTarget(
name: "GRPCInterceptorsCoreTests",
dependencies: [
.target(name: "GRPCInterceptorsCore")
],
swiftSettings: defaultSwiftSettings
),

// gRPC OTel metrics interceptors.
.target(
name: "GRPCOTelMetricsInterceptors",
dependencies: [
.target(name: "GRPCInterceptorsCore"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "Metrics", package: "swift-metrics"),
],
swiftSettings: defaultSwiftSettings
),
.testTarget(
name: "GRPCOTelMetricsInterceptorsTests",
dependencies: [
.target(name: "GRPCOTelMetricsInterceptors"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "Metrics", package: "swift-metrics"),
.product(name: "MetricsTestKit", package: "swift-metrics"),
],
swiftSettings: defaultSwiftSettings
),

// gRPC OTel tracing interceptors.
.target(
name: "GRPCOTelTracingInterceptors",
dependencies: [
.target(name: "GRPCInterceptorsCore"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "Tracing", package: "swift-distributed-tracing"),
],
Expand Down
32 changes: 32 additions & 0 deletions Sources/GRPCInterceptorsCore/BaseContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright 2024-2025, gRPC Authors All rights reserved.
*
* 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.
*/

package import GRPCCore

@available(gRPCSwiftExtras 2.0, *)
package protocol BaseContext {
var descriptor: MethodDescriptor { get }

var remotePeer: String { get }

var localPeer: String { get }
}

@available(gRPCSwiftExtras 2.0, *)
extension ClientContext: BaseContext {}

@available(gRPCSwiftExtras 2.0, *)
extension ServerContext: BaseContext {}
38 changes: 38 additions & 0 deletions Sources/GRPCInterceptorsCore/GRPCOTelAttributeKeys.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2024-2025, gRPC Authors All rights reserved.
*
* 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.
*/

package enum GRPCOTelAttributeKeys {
package static let rpcSystem = "rpc.system"
package static let rpcMethod = "rpc.method"
package static let rpcService = "rpc.service"
package static let rpcMessageID = "rpc.message.id"
package static let rpcMessageType = "rpc.message.type"
package static let grpcStatusCode = "rpc.grpc.status_code"

package static let serverAddress = "server.address"
package static let serverPort = "server.port"

package static let clientAddress = "client.address"
package static let clientPort = "client.port"

package static let networkTransport = "network.transport"
package static let networkType = "network.type"
package static let networkPeerAddress = "network.peer.address"
package static let networkPeerPort = "network.peer.port"

package static let requestMetadataPrefix = "rpc.grpc.request.metadata."
package static let responseMetadataPrefix = "rpc.grpc.response.metadata."
}
81 changes: 81 additions & 0 deletions Sources/GRPCInterceptorsCore/HookedAsyncSequence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2025, gRPC Authors All rights reserved.
*
* 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.
*/

@available(gRPCSwiftExtras 2.0, *)
package struct HookedRPCAsyncSequence<Wrapped: AsyncSequence & Sendable>: AsyncSequence, Sendable
where Wrapped.Element: Sendable {
private let wrapped: Wrapped

private let forEachElement: @Sendable (Wrapped.Element) -> Void
private let onFinish: @Sendable ((any Error)?) -> Void

package init(
wrapping sequence: Wrapped,
forEachElement: @escaping @Sendable (Wrapped.Element) -> Void,
onFinish: @escaping @Sendable ((any Error)?) -> Void
) {
self.wrapped = sequence
self.forEachElement = forEachElement
self.onFinish = onFinish
}

package func makeAsyncIterator() -> HookedAsyncIterator {
HookedAsyncIterator(
self.wrapped,
forEachElement: self.forEachElement,
onFinish: self.onFinish
)
}

package struct HookedAsyncIterator: AsyncIteratorProtocol {
package typealias Element = Wrapped.Element

private var wrapped: Wrapped.AsyncIterator
private let forEachElement: @Sendable (Wrapped.Element) -> Void
private let onFinish: @Sendable ((any Error)?) -> Void

init(
_ sequence: Wrapped,
forEachElement: @escaping @Sendable (Wrapped.Element) -> Void,
onFinish: @escaping @Sendable ((any Error)?) -> Void
) {
self.wrapped = sequence.makeAsyncIterator()
self.forEachElement = forEachElement
self.onFinish = onFinish
}

package mutating func next(
isolation actor: isolated (any Actor)?
) async throws(Wrapped.Failure) -> Wrapped.Element? {
do {
if let element = try await self.wrapped.next(isolation: actor) {
self.forEachElement(element)
return element
} else {
self.onFinish(nil)
return nil
}
} catch {
self.onFinish(error)
throw error
}
}

package mutating func next() async throws -> Wrapped.Element? {
try await self.next(isolation: nil)
}
}
}
40 changes: 40 additions & 0 deletions Sources/GRPCInterceptorsCore/HookedWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2024, gRPC Authors All rights reserved.
*
* 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.
*/
package import GRPCCore

@available(gRPCSwiftExtras 2.0, *)
package struct HookedWriter<Element: Sendable>: RPCWriterProtocol {
private let writer: any RPCWriterProtocol<Element>
private let afterEachWrite: @Sendable () -> Void

package init(
wrapping other: some RPCWriterProtocol<Element>,
afterEachWrite: @Sendable @escaping () -> Void
) {
self.writer = other
self.afterEachWrite = afterEachWrite
}

package func write(_ element: Element) async throws {
try await self.writer.write(element)
self.afterEachWrite()
}

package func write(contentsOf elements: some Sequence<Element>) async throws {
try await self.writer.write(contentsOf: elements)
self.afterEachWrite()
}
}
44 changes: 44 additions & 0 deletions Sources/GRPCInterceptorsCore/Int+IpAddressPortStringBytes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2024-2025, gRPC Authors All rights reserved.
*
* 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.
*/

extension Int {
package init?(ipAddressPortStringBytes: some Collection<UInt8>) {
guard (1 ... 5).contains(ipAddressPortStringBytes.count) else {
// Valid IP port values go up to 2^16-1 (65535), which is 5 digits long.
// If the string we get is over 5 characters, we know for sure that this is an invalid port.
// If it's empty, we also know it's invalid as we need at least one digit.
return nil
}

var value = 0
for utf8Char in ipAddressPortStringBytes {
value &*= 10
guard (UInt8(ascii: "0") ... UInt8(ascii: "9")).contains(utf8Char) else {
// non-digit character
return nil
}
value &+= Int(utf8Char &- UInt8(ascii: "0"))
}

guard value <= Int(UInt16.max) else {
// Valid IP port values go up to 2^16-1.
// If a number greater than this was given, it can't be a valid port.
return nil
}

self = value
}
}
87 changes: 87 additions & 0 deletions Sources/GRPCInterceptorsCore/PeerAddress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2024-2025, gRPC Authors All rights reserved.
*
* 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.
*/

internal import GRPCCore

package enum PeerAddress: Equatable {
case ipv4(address: String, port: Int?)
case ipv6(address: String, port: Int?)
case unixDomainSocket(path: String)

package init?(_ address: String) {
// We expect this address to be of one of these formats:
// - ipv4:<host>:<port> for ipv4 addresses
// - ipv6:[<host>]:<port> for ipv6 addresses
// - unix:<uds-pathname> for UNIX domain sockets
let addressUTF8View = address.utf8

// First get the first component so that we know what type of address we're dealing with
let firstColonIndex = addressUTF8View.firstIndex(of: UInt8(ascii: ":"))

guard let firstColonIndex else {
// This is some unexpected/unknown format
return nil
}

let addressType = addressUTF8View[..<firstColonIndex]

var addressWithoutType = addressUTF8View[firstColonIndex...]
addressWithoutType.removeFirst()

// Check what type the transport is...
if addressType.elementsEqual("ipv4".utf8) {
guard let addressColon = addressWithoutType.firstIndex(of: UInt8(ascii: ":")) else {
// This is some unexpected/unknown format
return nil
}

let hostComponent = addressWithoutType[..<addressColon]
var portComponent = addressWithoutType[addressColon...]
portComponent.removeFirst()

if let host = String(hostComponent), let port = Int(ipAddressPortStringBytes: portComponent) {
self = .ipv4(address: host, port: port)
} else {
return nil
}
} else if addressType.elementsEqual("ipv6".utf8) {
guard let lastColonIndex = addressWithoutType.lastIndex(of: UInt8(ascii: ":")) else {
// This is some unexpected/unknown format
return nil
}

var hostComponent = addressWithoutType[..<lastColonIndex]
var portComponent = addressWithoutType[lastColonIndex...]
portComponent.removeFirst()

if let firstBracket = hostComponent.popFirst(), let lastBracket = hostComponent.popLast(),
firstBracket == UInt8(ascii: "["), lastBracket == UInt8(ascii: "]"),
let host = String(hostComponent), let port = Int(ipAddressPortStringBytes: portComponent)
{
self = .ipv6(address: host, port: port)
} else {
// This is some unexpected/unknown format
return nil
}
} else if addressType.elementsEqual("unix".utf8) {
// Whatever comes after "unix:" is the <pathname>
self = .unixDomainSocket(path: String(addressWithoutType) ?? "")
} else {
// This is some unexpected/unknown format
return nil
}
}
}
Loading