├── .spi.yml ├── CODE_OF_CONDUCT.md ├── .mailmap ├── .editorconfig ├── .gitignore ├── Examples └── ServiceIntegration │ ├── .gitignore │ ├── grafana │ └── provisioning │ │ └── dashboards │ │ ├── service-process-metrics-dashboard.yaml │ │ └── grafana-dashboard-service-process-metrics.json │ ├── docker-compose.yaml │ ├── Dockerfile │ ├── README.md │ ├── Package.swift │ └── Sources │ └── main.swift ├── dev └── git.commit.template ├── .github ├── release.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.md └── workflows │ ├── pull_request_label.yml │ ├── main.yml │ └── pull_request.yml ├── .licenseignore ├── CONTRIBUTORS.txt ├── NOTICE.txt ├── Scripts └── test-examples.sh ├── Sources └── SystemMetrics │ ├── Docs.docc │ ├── Proposals │ │ ├── SSM-NNNN.md │ │ ├── Proposals.md │ │ ├── SSM-0002.md │ │ └── SSM-0001.md │ └── index.md │ ├── SystemMetricsMonitorConfiguration.swift │ ├── SystemMetricsMonitor+Darwin.swift │ ├── SystemMetricsMonitor+Linux.swift │ └── SystemMetricsMonitor.swift ├── Tests └── SystemMetricsTests │ ├── WaitForUntil.swift │ ├── LinuxDataProviderTests.swift │ ├── DarwinDataProviderTests.swift │ └── SystemMetricsMonitorTests.swift ├── README.md ├── .swift-format ├── Package.swift ├── CONTRIBUTING.md └── LICENSE.txt /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [SystemMetrics] 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | The code of conduct for this project can be found at https://swift.org/code-of-conduct. 4 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Tomer Doron 2 | Konrad `ktoso` Malawski 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | .xcode 6 | .SourceKitten 7 | *.orig 8 | .swiftpm 9 | .idea 10 | Package.resolved 11 | .vscode 12 | -------------------------------------------------------------------------------- /Examples/ServiceIntegration/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | .xcode 6 | .SourceKitten 7 | *.orig 8 | .swiftpm 9 | .idea 10 | Package.resolved 11 | .vscode 12 | -------------------------------------------------------------------------------- /Examples/ServiceIntegration/grafana/provisioning/dashboards/service-process-metrics-dashboard.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: "Service Process Metrics" 5 | type: file 6 | options: 7 | path: /otel-lgtm/grafana-dashboard-service-process-metrics.json 8 | foldersFromFilesStructure: false 9 | -------------------------------------------------------------------------------- /dev/git.commit.template: -------------------------------------------------------------------------------- 1 | One line description of your change 2 | 3 | Motivation: 4 | 5 | Explain here the context, and why you're making that change. 6 | What is the problem you're trying to solve. 7 | 8 | Modifications: 9 | 10 | Describe the modifications you've done. 11 | 12 | Result: 13 | 14 | After your change, what will change. 15 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: SemVer Major 4 | labels: 5 | - ⚠️ semver/major 6 | - title: SemVer Minor 7 | labels: 8 | - 🆕 semver/minor 9 | - title: SemVer Patch 10 | labels: 11 | - 🔨 semver/patch 12 | - title: Other Changes 13 | labels: 14 | - semver/none 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | _[One line description of your change]_ 2 | 3 | ### Motivation: 4 | 5 | _[Explain here the context, and why you're making that change. What is the problem you're trying to solve.]_ 6 | 7 | ### Modifications: 8 | 9 | _[Describe the modifications you've done.]_ 10 | 11 | ### Result: 12 | 13 | _[After your change, what will change.]_ 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected behavior 2 | _[what you expected to happen]_ 3 | 4 | ### Actual behavior 5 | _[what actually happened]_ 6 | 7 | ### Steps to reproduce 8 | 9 | 1. ... 10 | 2. ... 11 | 12 | ### If possible, minimal yet complete reproducer code (or URL to code) 13 | 14 | _[anything to help us reproducing the issue]_ 15 | 16 | ### SwiftMetrics version/commit hash 17 | 18 | _[the SwiftMetrics tag/commit hash]_ 19 | 20 | ### Swift & OS version (output of `swift --version && uname -a`) 21 | -------------------------------------------------------------------------------- /.github/workflows/pull_request_label.yml: -------------------------------------------------------------------------------- 1 | name: PR label 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | types: [labeled, unlabeled, opened, reopened, synchronize] 9 | 10 | jobs: 11 | semver-label-check: 12 | name: Semantic version label check 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 1 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v6 18 | with: 19 | persist-credentials: false 20 | - name: Check for Semantic Version label 21 | uses: apple/swift-nio/.github/actions/pull_request_semver_label_checker@main 22 | -------------------------------------------------------------------------------- /.licenseignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | **/.gitignore 3 | .licenseignore 4 | .gitattributes 5 | .git-blame-ignore-revs 6 | .mailfilter 7 | .mailmap 8 | .spi.yml 9 | .swift-format 10 | .editorconfig 11 | .github/* 12 | *.md 13 | *.txt 14 | *.yml 15 | *.yaml 16 | *.json 17 | Package.swift 18 | **/Package.swift 19 | Package@-*.swift 20 | **/Package@-*.swift 21 | Package.resolved 22 | **/Package.resolved 23 | Makefile 24 | *.modulemap 25 | **/*.modulemap 26 | **/*.docc/* 27 | *.xcprivacy 28 | **/*.xcprivacy 29 | *.symlink 30 | **/*.symlink 31 | Dockerfile 32 | **/Dockerfile 33 | Snippets/* 34 | dev/git.commit.template 35 | .unacceptablelanguageignore 36 | Scripts/* 37 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | For the purpose of tracking copyright, this is the list of individuals and 2 | organizations who have contributed source code to the Swift Metrics API. 3 | 4 | For employees of an organization/company where the copyright of work done 5 | by employees of that company is held by the company itself, only the company 6 | needs to be listed here. 7 | 8 | ## COPYRIGHT HOLDERS 9 | 10 | - Apple Inc. (all contributors with '@apple.com') 11 | 12 | ### Contributors 13 | 14 | - Konrad `ktoso` Malawski 15 | - MrLotU 16 | 17 | **Updating this list** 18 | 19 | Please do not edit this file manually. It is generated using `./scripts/generate_contributors_list.sh`. If a name is misspelled or appearing multiple times: add an entry in `./.mailmap` 20 | -------------------------------------------------------------------------------- /Examples/ServiceIntegration/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | services: 3 | systemmetricsmonitor: 4 | build: 5 | context: ../.. 6 | dockerfile: Examples/ServiceIntegration/Dockerfile 7 | environment: 8 | LOG_LEVEL: debug 9 | OTEL_EXPORTER_OTLP_PROTOCOL: grpc 10 | OTEL_EXPORTER_OTLP_ENDPOINT: http://grafana:4317 11 | ports: 12 | - "8080:8080" 13 | depends_on: 14 | - grafana 15 | 16 | grafana: 17 | image: grafana/otel-lgtm:latest 18 | environment: 19 | - GF_PATHS_DATA=/data/grafana 20 | volumes: 21 | - ./grafana/provisioning/dashboards/service-process-metrics-dashboard.yaml:/otel-lgtm/grafana/conf/provisioning/dashboards/service-process-metrics-dashboard.yaml 22 | - ./grafana/provisioning/dashboards/grafana-dashboard-service-process-metrics.json:/otel-lgtm/grafana-dashboard-service-process-metrics.json 23 | ports: 24 | - "3000:3000" 25 | - "4317:4317" 26 | - "4318:4318" 27 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | 2 | The SwiftMetrics Project 3 | ======================== 4 | 5 | Please visit the SwiftMetrics web site for more information: 6 | 7 | * https://github.com/apple/swift-metrics 8 | 9 | Copyright 2018, 2019 The SwiftMetrics Project 10 | 11 | The SwiftMetrics Project licenses this file to you under the Apache License, 12 | version 2.0 (the "License"); you may not use this file except in compliance 13 | with the License. You may obtain a copy of the License at: 14 | 15 | https://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations 21 | under the License. 22 | 23 | Also, please refer to each LICENSE..txt file, which is located in 24 | the 'license' directory of the distribution file, for the license terms of the 25 | components that this product depends on. 26 | 27 | ------------------------------------------------------------------------------- 28 | 29 | This product contains a derivation of the lock implementation and various scripts from SwiftNIO. 30 | 31 | * LICENSE (Apache License 2.0): 32 | * https://www.apache.org/licenses/LICENSE-2.0 33 | * HOMEPAGE: 34 | * https://github.com/apple/swift-nio 35 | -------------------------------------------------------------------------------- /Scripts/test-examples.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | log() { printf -- "** %s\n" "$*" >&2; } 5 | error() { printf -- "** ERROR: %s\n" "$*" >&2; } 6 | fatal() { error "$@"; exit 1; } 7 | 8 | log "Checking required executables..." 9 | SWIFT_BIN=${SWIFT_BIN:-$(command -v swift || xcrun -f swift)} || fatal "SWIFT_BIN unset and no swift on PATH" 10 | 11 | CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 12 | REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" 13 | PACKAGE_NAME=$(basename "$REPO_ROOT") 14 | TMP_DIR=$(/usr/bin/mktemp -d -p "${TMPDIR-/tmp}" "$(basename "$0").XXXXXXXXXX") 15 | WORK_DIR="$TMP_DIR/$PACKAGE_NAME" 16 | /bin/mkdir -p "$WORK_DIR" 17 | 18 | git archive HEAD "${REPO_ROOT}" --format tar | tar -C "${WORK_DIR}" -xvf- 19 | 20 | EXAMPLES_PATH="$WORK_DIR/Examples" 21 | for EXAMPLE_PACKAGE_PATH in $(find "${EXAMPLES_PATH}" -maxdepth 2 -name Package.swift -type f -print0 | xargs -0 dirname | sort); do 22 | EXAMPLE_PACKAGE_NAME=$(basename "$EXAMPLE_PACKAGE_PATH") 23 | log "Building example package: ${EXAMPLE_PACKAGE_NAME}" 24 | "${SWIFT_BIN}" build --build-tests \ 25 | --package-path "${EXAMPLE_PACKAGE_PATH}" \ 26 | --skip-update 27 | log "✅ Successfully built the example package ${EXAMPLE_PACKAGE_NAME}." 28 | 29 | if [ -d "${EXAMPLE_PACKAGE_PATH}/Tests" ]; then 30 | log "Running tests for example package: ${EXAMPLE_PACKAGE_NAME}" 31 | "${SWIFT_BIN}" test \ 32 | --package-path "${EXAMPLE_PACKAGE_PATH}" \ 33 | --skip-update 34 | log "✅ Passed the tests for the example package ${EXAMPLE_PACKAGE_NAME}." 35 | fi 36 | done 37 | -------------------------------------------------------------------------------- /Examples/ServiceIntegration/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG SWIFT_VERSION=6.2 2 | ARG UBI_VERSION=9 3 | 4 | FROM docker.apple.com/base-images/ubi${UBI_VERSION}/swift${SWIFT_VERSION}-builder AS builder 5 | 6 | WORKDIR /swift-metrics-extras 7 | 8 | # First just resolve dependencies. 9 | # This creates a cached layer that can be reused 10 | # as long as your Package.swift/Package.resolved 11 | # files do not change. 12 | COPY ./Examples/ServiceIntegration/Package.* ./Examples/ServiceIntegration/ 13 | COPY ./Package.* ./ 14 | RUN swift package --package-path Examples/ServiceIntegration resolve \ 15 | $([ -f ./Examples/ServiceIntegration/Package.resolved ] && echo "--force-resolved-versions" || true) 16 | 17 | # Copy the Sources dirs into container 18 | COPY ./Sources ./Sources 19 | COPY ./Examples/ServiceIntegration/Sources ./Examples/ServiceIntegration/Sources 20 | 21 | # Build the application, with optimizations 22 | RUN swift build --package-path Examples/ServiceIntegration -c release --product ServiceIntegrationExample 23 | 24 | FROM docker.apple.com/base-images/ubi${UBI_VERSION}-minimal/swift${SWIFT_VERSION}-runtime 25 | 26 | USER root 27 | RUN mkdir -p /app/bin 28 | COPY --from=builder /swift-metrics-extras/Examples/ServiceIntegration/.build/release/ServiceIntegrationExample /app/bin 29 | RUN mkdir -p /logs && chown $NON_ROOT_USER_ID /logs 30 | USER $NON_ROOT_USER_ID 31 | 32 | WORKDIR /app 33 | # If crash log creation times out in a prod environment, consider changing to symbolicate=fast 34 | # For details, check out https://docs.apple.com/documentation/swift-on-server/collecting-crash-logs 35 | ENV SWIFT_BACKTRACE=interactive=no,color=no,output-to=/logs,format=json,symbolicate=full 36 | CMD /app/bin/ServiceIntegrationExample serve 37 | EXPOSE 8080 38 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/Docs.docc/Proposals/SSM-NNNN.md: -------------------------------------------------------------------------------- 1 | # SSM-NNNN: Feature name 2 | 3 | Feature abstract – a one sentence summary. 4 | 5 | ## Overview 6 | 7 | - Proposal: SSM-NNNN 8 | - Author(s): [Author 1](https://github.com/swiftdev), [Author 2](https://github.com/swiftdev) 9 | - Status: **Awaiting Review** 10 | - Issue: [apple/swift-metrics-extras#1](https://github.com/apple/swift-metrics-extras/issues/1) 11 | - Implementation: 12 | - [apple/swift-metrics-extras#1](https://github.com/apple/swift-metrics-extras/pull/1) 13 | - Feature flag: `proposalNNNN` 14 | - Related links: 15 | - [Lightweight proposals process description](https://github.com/apple/swift-metrics-extras/blob/main/Sources/SystemMetrics/Docs.docc/Proposals/Proposals.md) 16 | 17 | ### Introduction 18 | 19 | A short, one-sentence overview of the feature or change. 20 | 21 | ### Motivation 22 | 23 | Describe the problems that this proposal aims to address, and what workarounds adopters have to employ currently, if any. 24 | 25 | ### Proposed solution 26 | 27 | Describe your solution to the problem. Provide examples and describe how they work. Show how your solution is better than current workarounds. 28 | 29 | This section should focus on what will change for the _adopters_ of Swift Metrics Extras. 30 | 31 | ### Detailed design 32 | 33 | Describe the implementation of the feature, a link to a prototype implementation is encouraged here. 34 | 35 | This section should focus on what will change for the _contributors_ to Swift Metrics Extras. 36 | 37 | ### API stability 38 | 39 | Discuss the API implications, making sure to considering all of: 40 | - existing metrics data provider implementations 41 | - existing users of `SystemMetrics` 42 | 43 | ### Future directions 44 | 45 | Discuss any potential future improvements to the feature. 46 | 47 | ### Alternatives considered 48 | 49 | Discuss the alternative solutions considered, even during the review process itself. 50 | -------------------------------------------------------------------------------- /Examples/ServiceIntegration/README.md: -------------------------------------------------------------------------------- 1 | # Service Integration Example 2 | 3 | This example demonstrates how to integrate `SystemMetricsMonitor` with `swift-service-lifecycle` to automatically collect and report system metrics for your service. 4 | 5 | ## Overview 6 | 7 | The example shows: 8 | - How to use `SystemMetricsMonitor` as a `Service` within a `ServiceGroup` 9 | - How to integrate it with the global `MetricsSystem` using `MetricsSystem.bootstrap()` 10 | 11 | ## Alternative Usage 12 | 13 | Instead of using the global `MetricsSystem.bootstrap()`, you can inject a custom `MetricsFactory` directly: 14 | 15 | ```swift 16 | let customMetrics = MyMetricsBackendImplementation() 17 | let systemMetricsMonitor = SystemMetricsMonitor( 18 | metricsFactory: customMetrics, 19 | logger: logger 20 | ) 21 | ``` 22 | 23 | This approach decouples the monitor from global state and allows you to use different metrics backends for different components. 24 | 25 | ## Running the Example 26 | 27 | From the `swift-metrics-extras` package root run: 28 | 29 | ```bash 30 | docker-compose -f Examples/ServiceIntegration/docker-compose.yaml up --build 31 | ``` 32 | 33 | This will build and run 2 containers: `grafana` and `systemmetricsmonitor`. 34 | 35 | ## Grafana Dashboard 36 | 37 | This example includes a preconfigured Grafana dashboard for the collected metrics. 38 | Once the example is running, access Grafana at http://localhost:3000 and navigate to the "Process System Metrics" dashboard. 39 | The dashboard provides four visualizations that map to the metrics collected by ``SystemMetricsMonitor``: 40 | 41 | 1. Service Uptime Timeline based on the reported `process_start_time_seconds`. 42 | 43 | 1. CPU Usage % calculated as `rate(process_cpu_seconds_total)`. 44 | 45 | 1. Residential Memory (`process_resident_memory_bytes`) and Virtual Memory (`process_virtual_memory_bytes`) consumption. 46 | 47 | 1. Open File Descriptors (`process_open_fds`) and Max File Descriptors (`process_max_fds`) 48 | -------------------------------------------------------------------------------- /Tests/SystemMetricsTests/WaitForUntil.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2018-2020 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | 17 | struct TimeoutError: Error {} 18 | struct OperationFinishedPrematurelyError: Error {} 19 | 20 | func wait( 21 | noLongerThan duration: Duration, 22 | for condition: @escaping @Sendable () async throws -> Bool, 23 | `while` operation: @escaping @Sendable () async throws -> Void 24 | ) async throws -> Bool { 25 | try await withThrowingTaskGroup(of: Bool.self) { group in 26 | // Start the operation 27 | group.addTask { 28 | try await operation() 29 | throw OperationFinishedPrematurelyError() 30 | } 31 | 32 | // Add timeout 33 | group.addTask { 34 | try await Task.sleep(for: duration) 35 | throw TimeoutError() 36 | } 37 | 38 | // Keep monitoring the condition 39 | group.addTask { 40 | var conditionResult = try await condition() 41 | while !conditionResult { 42 | try await Task.sleep(for: .seconds(0.5)) 43 | conditionResult = try await condition() 44 | } 45 | return conditionResult 46 | } 47 | 48 | // Who is the first — timeout, condition or faulty operation? 49 | let conditionMet = try await group.next() 50 | group.cancelAll() 51 | if let conditionMet = conditionMet { 52 | return conditionMet 53 | } 54 | return false 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Examples/ServiceIntegration/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | //===----------------------------------------------------------------------===// 3 | // 4 | // This source file is part of the Swift Metrics API open source project 5 | // 6 | // Copyright (c) 2025 Apple Inc. and the Swift Metrics API project authors 7 | // Licensed under Apache License v2.0 8 | // 9 | // See LICENSE.txt for license information 10 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 11 | // 12 | // SPDX-License-Identifier: Apache-2.0 13 | // 14 | //===----------------------------------------------------------------------===// 15 | 16 | import PackageDescription 17 | 18 | let package = Package( 19 | name: "ServiceIntegrationExample", 20 | platforms: [ 21 | .macOS(.v13), 22 | .iOS(.v16), 23 | .watchOS(.v9), 24 | .tvOS(.v16), 25 | ], 26 | dependencies: [ 27 | .package(path: "../.."), 28 | .package(url: "https://github.com/apple/swift-metrics.git", from: "2.3.2"), 29 | .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), 30 | .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), 31 | .package(url: "https://github.com/swift-otel/swift-otel", from: "1.0.0"), 32 | .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), 33 | ], 34 | targets: [ 35 | .executableTarget( 36 | name: "ServiceIntegrationExample", 37 | dependencies: [ 38 | .product(name: "SystemMetrics", package: "swift-metrics-extras"), 39 | .product(name: "Metrics", package: "swift-metrics"), 40 | .product(name: "MetricsTestKit", package: "swift-metrics"), 41 | .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), 42 | .product(name: "UnixSignals", package: "swift-service-lifecycle"), 43 | .product(name: "Logging", package: "swift-log"), 44 | .product(name: "OTel", package: "swift-otel"), 45 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), 46 | ], 47 | path: "Sources" 48 | ) 49 | ] 50 | ) 51 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/Docs.docc/Proposals/Proposals.md: -------------------------------------------------------------------------------- 1 | # Proposals 2 | 3 | Collaborate on API changes to Swift Metrics Extras by writing a proposal. 4 | 5 | ## Overview 6 | 7 | For non-trivial changes that affect the public API, the Swift Metrics Extras project adopts a lightweight version of the [Swift Evolution](https://github.com/apple/swift-evolution/blob/main/process.md) process. 8 | 9 | Writing a proposal first helps discuss multiple possible solutions early, apply useful feedback from other contributors, and avoid reimplementing the same feature multiple times. 10 | 11 | While it's encouraged to get feedback by opening a pull request with a proposal early in the process, it's also important to consider the complexity of the implementation when evaluating different solutions. For example, this might mean including a link to a branch containing a prototype implementation of the feature in the pull request description. 12 | 13 | > Note: The goal of this process is to help solicit feedback from the whole community around the project, and we will continue to refine the proposal process itself. Use your best judgement, and don't hesitate to propose changes to the proposal structure itself! 14 | 15 | ### Steps 16 | 17 | 1. Make sure there's a GitHub issue for the feature or change you would like to propose. 18 | 2. Duplicate the `SSM-NNNN.md` document and replace `NNNN` with the next available proposal number. 19 | 3. Link the GitHub issue from your proposal, and fill in the proposal. 20 | 4. Open a pull request with your proposal and solicit feedback from other contributors. 21 | 5. Once a maintainer confirms that the proposal is ready for review, the state is updated accordingly. The review period is 7 days, and ends when one of the maintainers marks the proposal as Ready for Implementation, or Deferred. 22 | 6. Before the pull request is merged, there should be an implementation ready, either in the same pull request, or a separate one, linked from the proposal. 23 | 7. The proposal is considered Approved once the implementation, proposal PRs have been merged, and, if originally disabled by a feature flag, feature flag enabled unconditionally. 24 | 25 | If you have any questions, ask in an issue on GitHub. 26 | 27 | ### Possible review states 28 | 29 | - Awaiting Review 30 | - In Review 31 | - Ready for Implementation 32 | - In Preview 33 | - Approved 34 | - Deferred 35 | 36 | ## Topics 37 | 38 | - 39 | - 40 | -------------------------------------------------------------------------------- /Examples/ServiceIntegration/Sources/main.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import AsyncAlgorithms 16 | import Logging 17 | import Metrics 18 | import MetricsTestKit 19 | import OTel 20 | import ServiceLifecycle 21 | import SystemMetrics 22 | import UnixSignals 23 | 24 | struct FooService: Service { 25 | let logger: Logger 26 | 27 | func run() async throws { 28 | self.logger.notice("FooService starting") 29 | for await _ in AsyncTimerSequence(interval: .seconds(0.01), clock: .continuous) 30 | .cancelOnGracefulShutdown() 31 | { 32 | let j = 42 33 | for i in 0...1000 { 34 | let k = i * j 35 | self.logger.trace("FooService is still running", metadata: ["k": "\(k)"]) 36 | } 37 | } 38 | self.logger.notice("FooService done") 39 | } 40 | } 41 | 42 | @main 43 | struct Application { 44 | static func main() async throws { 45 | let logger = Logger(label: "Application") 46 | 47 | // Bootstrap with some custom metrics backend 48 | var otelConfig = OTel.Configuration.default 49 | otelConfig.logs.enabled = false 50 | otelConfig.serviceName = "ServiceIntegrationExample" 51 | let otelService = try OTel.bootstrap(configuration: otelConfig) 52 | 53 | // Create a service simulating some important work 54 | let service = FooService(logger: logger) 55 | let systemMetricsMonitor = SystemMetricsMonitor( 56 | configuration: .init(pollInterval: .seconds(5)), 57 | logger: logger 58 | ) 59 | 60 | let serviceGroup = ServiceGroup( 61 | services: [service, systemMetricsMonitor, otelService], 62 | gracefulShutdownSignals: [.sigint], 63 | cancellationSignals: [.sigterm], 64 | logger: logger 65 | ) 66 | 67 | try await serviceGroup.run() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift System Metrics 2 | 3 | Automatically collects process-level system metrics (memory, CPU, file descriptors) and reports them through the [SwiftMetrics](https://github.com/apple/swift-metrics) API. 4 | 5 | ## Collected Metrics 6 | 7 | The following metrics are collected and reported as gauges: 8 | 9 | - **Virtual Memory** (`process_virtual_memory_bytes`) - Virtual memory size in bytes 10 | - **Resident Memory** (`process_resident_memory_bytes`) - Resident Set Size (RSS) in bytes 11 | - **CPU Time** (`process_cpu_seconds_total`) - Total user and system CPU time spent in seconds 12 | - **Process Start Time** (`process_start_time_seconds`) - Process start time since Unix epoch in seconds 13 | - **Open File Descriptors** (`process_open_fds`) - Current number of open file descriptors 14 | - **Max File Descriptors** (`process_max_fds`) - Maximum number of open file descriptors allowed 15 | 16 | ## Quick start 17 | 18 | ```swift 19 | import Logging 20 | import SystemMetrics 21 | 22 | // Create a logger, or use one of the existing loggers 23 | let logger = Logger(label: "MyLogger") 24 | 25 | // Create the monitor 26 | let monitor = SystemMetricsMonitor(logger: logger) 27 | 28 | // Create the service 29 | let serviceGroup = ServiceGroup( 30 | services: [monitor], 31 | gracefulShutdownSignals: [.sigint], 32 | cancellationSignals: [.sigterm], 33 | logger: logger 34 | ) 35 | 36 | // Start collecting metrics 37 | try await serviceGroup.run() 38 | ``` 39 | 40 | See the [SystemMetrics documentation](https://swiftpackageindex.com/apple/swift-metrics-extras/documentation/systemmetrics) for details. 41 | 42 | ## Installation 43 | 44 | Add SwiftMetricsExtras as a dependency in your `Package.swift`: 45 | 46 | ```swift 47 | dependencies: [ 48 | .package(url: "https://github.com/apple/swift-metrics-extras.git", from: "1.0.0") 49 | ] 50 | ``` 51 | 52 | Then add ``SystemMetrics`` to your target: 53 | 54 | ```swift 55 | .target( 56 | name: "YourTarget", 57 | dependencies: [ 58 | .product(name: "SystemMetrics", package: "swift-metrics-extras") 59 | ] 60 | ) 61 | ``` 62 | 63 | ## Example & Grafana Dashboard 64 | 65 | A complete working example with a pre-built Grafana dashboard is available in [Examples/ServiceIntegration](Examples/ServiceIntegration). The example includes: 66 | 67 | - `SwiftServiceLifecycle` integration. 68 | - `SwiftMetrics` configured to export the metrics. 69 | - Docker Compose setup with Grafana container. 70 | - A provisioned Grafana dashboard visualizing all the collected metrics. 71 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/Docs.docc/Proposals/SSM-0002.md: -------------------------------------------------------------------------------- 1 | # SSM-0002: Rename `swift-metrics-extras` to better reflect its purpose 2 | 3 | Rename the package and repository from `swift-metrics-extras` to `swift-system-metrics` to better reflect its single, focused purpose. 4 | 5 | ## Overview 6 | 7 | - Proposal: SSM-0002 8 | - Author(s): [Vladimir Kukushkin](https://github.com/kukushechkin) 9 | - Status: **Ready for Implementation** 10 | - Issue: [apple/swift-metrics-extras#69](https://github.com/apple/swift-metrics-extras/issues/69) 11 | - Implementation: TBA 12 | - Related links: 13 | - [Lightweight proposals process description](https://github.com/apple/swift-metrics-extras/blob/main/Sources/SystemMetrics/Docs.docc/Proposals/Proposals.md) 14 | 15 | ### Introduction 16 | 17 | Rename the `swift-metrics-extras` package and repository to `swift-system-metrics` to accurately represent its purpose. 18 | 19 | ### Motivation 20 | 21 | The package name `swift-metrics-extras` suggests a collection of various metrics-related utilities, but the package contains only one component collecting and reporting process system metrics. This creates confusion for potential users and does not accurately describe the package's purpose. A more descriptive name would make it easier for users to discover and understand what the package does. 22 | 23 | ### Proposed solution 24 | 25 | Rename the package and repository from `swift-metrics-extras` to `swift-system-metrics`. This name directly describes the package's single responsibility: collecting system metrics. 26 | 27 | ### Detailed design 28 | 29 | - Rename the GitHub repository from `apple/swift-metrics-extras` to `apple/swift-system-metrics` (GitHub automatically sets up redirects from the old repository URL to the new one). 30 | - Update the package name in `Package.swift`. 31 | - Update all documentation references to reflect the new name. 32 | - Update Swift Package Index [entry](https://github.com/SwiftPackageIndex/PackageList/blob/main/packages.json#L637). 33 | 34 | ### API stability 35 | 36 | This change does not affect the API surface at all. Users do not need to update their `Package.swift` dependencies to the new repository URL immediately. 37 | 38 | ### Future directions 39 | 40 | None specific to this proposal. 41 | 42 | ### Alternatives considered 43 | 44 | **Keep the current name.** This would maintain continuity but perpetuate the confusion about the package's scope and purpose, potentially inviting PRs with random functionality. 45 | **Use `swift-process-metrics`.** While more specific, `swift-system-metrics` gives more freedom in the scope of collected metrics, not limiting them to just process metrics. 46 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version" : 1, 3 | "indentation" : { 4 | "spaces" : 4 5 | }, 6 | "tabWidth" : 4, 7 | "fileScopedDeclarationPrivacy" : { 8 | "accessLevel" : "private" 9 | }, 10 | "spacesAroundRangeFormationOperators" : false, 11 | "indentConditionalCompilationBlocks" : false, 12 | "indentSwitchCaseLabels" : false, 13 | "lineBreakAroundMultilineExpressionChainComponents" : false, 14 | "lineBreakBeforeControlFlowKeywords" : false, 15 | "lineBreakBeforeEachArgument" : true, 16 | "lineBreakBeforeEachGenericRequirement" : true, 17 | "lineLength" : 120, 18 | "maximumBlankLines" : 1, 19 | "respectsExistingLineBreaks" : true, 20 | "prioritizeKeepingFunctionOutputTogether" : true, 21 | "noAssignmentInExpressions" : { 22 | "allowedFunctions" : [ 23 | "XCTAssertNoThrow", 24 | "XCTAssertThrowsError" 25 | ] 26 | }, 27 | "rules" : { 28 | "AllPublicDeclarationsHaveDocumentation" : false, 29 | "AlwaysUseLiteralForEmptyCollectionInit" : false, 30 | "AlwaysUseLowerCamelCase" : false, 31 | "AmbiguousTrailingClosureOverload" : true, 32 | "BeginDocumentationCommentWithOneLineSummary" : false, 33 | "DoNotUseSemicolons" : true, 34 | "DontRepeatTypeInStaticProperties" : true, 35 | "FileScopedDeclarationPrivacy" : true, 36 | "FullyIndirectEnum" : true, 37 | "GroupNumericLiterals" : true, 38 | "IdentifiersMustBeASCII" : true, 39 | "NeverForceUnwrap" : false, 40 | "NeverUseForceTry" : false, 41 | "NeverUseImplicitlyUnwrappedOptionals" : false, 42 | "NoAccessLevelOnExtensionDeclaration" : true, 43 | "NoAssignmentInExpressions" : true, 44 | "NoBlockComments" : true, 45 | "NoCasesWithOnlyFallthrough" : true, 46 | "NoEmptyTrailingClosureParentheses" : true, 47 | "NoLabelsInCasePatterns" : true, 48 | "NoLeadingUnderscores" : false, 49 | "NoParensAroundConditions" : true, 50 | "NoVoidReturnOnFunctionSignature" : true, 51 | "OmitExplicitReturns" : true, 52 | "OneCasePerLine" : true, 53 | "OneVariableDeclarationPerLine" : true, 54 | "OnlyOneTrailingClosureArgument" : true, 55 | "OrderedImports" : true, 56 | "ReplaceForEachWithForLoop" : true, 57 | "ReturnVoidInsteadOfEmptyTuple" : true, 58 | "UseEarlyExits" : false, 59 | "UseExplicitNilCheckInConditions" : false, 60 | "UseLetInEveryBoundCaseVariable" : false, 61 | "UseShorthandTypeNames" : true, 62 | "UseSingleLinePropertyGetter" : false, 63 | "UseSynthesizedInitializer" : false, 64 | "UseTripleSlashForDocumentationComments" : true, 65 | "UseWhereClausesInForLoops" : false, 66 | "ValidateDocumentationComments" : false 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | schedule: 10 | - cron: "0 8,20 * * *" 11 | 12 | jobs: 13 | unit-tests: 14 | name: Unit tests 15 | uses: apple/swift-nio/.github/workflows/unit_tests.yml@main 16 | with: 17 | linux_6_0_arguments_override: "-Xswiftc -warnings-as-errors --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 18 | linux_6_1_arguments_override: "-Xswiftc -warnings-as-errors --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 19 | linux_6_2_arguments_override: "-Xswiftc -warnings-as-errors --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 20 | linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 21 | linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 22 | 23 | macos-tests: 24 | name: macOS tests 25 | uses: apple/swift-nio/.github/workflows/macos_tests.yml@main 26 | with: 27 | runner_pool: nightly 28 | build_scheme: swift-metrics-extras 29 | 30 | static-sdk: 31 | name: Static SDK 32 | uses: apple/swift-nio/.github/workflows/static_sdk.yml@main 33 | 34 | release-builds: 35 | name: Release builds 36 | uses: apple/swift-nio/.github/workflows/release_builds.yml@main 37 | 38 | construct-example-packages-matrix: 39 | name: Construct example packages matrix 40 | runs-on: ubuntu-latest 41 | outputs: 42 | example-packages-matrix: '${{ steps.generate-matrix.outputs.example-packages-matrix }}' 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | with: 47 | persist-credentials: false 48 | - id: generate-matrix 49 | run: echo "example-packages-matrix=$(curl -s https://raw.githubusercontent.com/apple/swift-nio/main/scripts/generate_matrix.sh | bash)" >> "$GITHUB_OUTPUT" 50 | env: 51 | MATRIX_LINUX_SETUP_COMMAND: git config --global --add safe.directory /swift-metrics-extras 52 | MATRIX_LINUX_COMMAND: ./Scripts/test-examples.sh 53 | MATRIX_LINUX_5_10_ENABLED: false 54 | MATRIX_LINUX_6_0_ENABLED: false 55 | MATRIX_LINUX_6_1_ENABLED: false 56 | MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false 57 | MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false 58 | 59 | example-packages: 60 | name: Example packages 61 | needs: construct-example-packages-matrix 62 | uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main 63 | with: 64 | name: "Example packages" 65 | matrix_string: '${{ needs.construct-example-packages-matrix.outputs.example-packages-matrix }}' 66 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | 10 | jobs: 11 | soundness: 12 | name: Soundness 13 | uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main 14 | with: 15 | license_header_check_project_name: "Swift Metrics API" 16 | 17 | unit-tests: 18 | name: Unit tests 19 | uses: apple/swift-nio/.github/workflows/unit_tests.yml@main 20 | with: 21 | linux_6_0_arguments_override: "-Xswiftc -warnings-as-errors --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 22 | linux_6_1_arguments_override: "-Xswiftc -warnings-as-errors --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 23 | linux_6_2_arguments_override: "-Xswiftc -warnings-as-errors --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 24 | linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 25 | linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable" 26 | 27 | macos-tests: 28 | name: macOS tests 29 | uses: apple/swift-nio/.github/workflows/macos_tests.yml@main 30 | with: 31 | runner_pool: general 32 | build_scheme: swift-metrics-extras 33 | 34 | static-sdk: 35 | name: Static SDK 36 | uses: apple/swift-nio/.github/workflows/static_sdk.yml@main 37 | 38 | release-builds: 39 | name: Release builds 40 | uses: apple/swift-nio/.github/workflows/release_builds.yml@main 41 | 42 | construct-example-packages-matrix: 43 | name: Construct example packages matrix 44 | runs-on: ubuntu-latest 45 | outputs: 46 | example-packages-matrix: '${{ steps.generate-matrix.outputs.example-packages-matrix }}' 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | with: 51 | persist-credentials: false 52 | - id: generate-matrix 53 | run: echo "example-packages-matrix=$(curl -s https://raw.githubusercontent.com/apple/swift-nio/main/scripts/generate_matrix.sh | bash)" >> "$GITHUB_OUTPUT" 54 | env: 55 | MATRIX_LINUX_SETUP_COMMAND: git config --global --add safe.directory /swift-metrics-extras 56 | MATRIX_LINUX_COMMAND: ./Scripts/test-examples.sh 57 | MATRIX_LINUX_5_10_ENABLED: false 58 | MATRIX_LINUX_6_0_ENABLED: false 59 | MATRIX_LINUX_6_1_ENABLED: false 60 | MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false 61 | MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false 62 | 63 | example-packages: 64 | name: Build Examples 65 | needs: construct-example-packages-matrix 66 | uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main 67 | with: 68 | name: "Example packages" 69 | matrix_string: '${{ needs.construct-example-packages-matrix.outputs.example-packages-matrix }}' 70 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | //===----------------------------------------------------------------------===// 3 | // 4 | // This source file is part of the Swift Metrics API open source project 5 | // 6 | // Copyright (c) 2018-2019 Apple Inc. and the Swift Metrics API project authors 7 | // Licensed under Apache License v2.0 8 | // 9 | // See LICENSE.txt for license information 10 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 11 | // 12 | // SPDX-License-Identifier: Apache-2.0 13 | // 14 | //===----------------------------------------------------------------------===// 15 | 16 | import PackageDescription 17 | 18 | let package = Package( 19 | name: "swift-metrics-extras", 20 | platforms: [.macOS(.v13), .iOS(.v16), .watchOS(.v9), .tvOS(.v16)], 21 | products: [ 22 | .library(name: "SystemMetrics", targets: ["SystemMetrics"]) 23 | ], 24 | dependencies: [ 25 | .package(url: "https://github.com/apple/swift-metrics.git", from: "2.3.2"), 26 | .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), 27 | .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), 28 | ], 29 | targets: [ 30 | .target( 31 | name: "SystemMetrics", 32 | dependencies: [ 33 | .product(name: "CoreMetrics", package: "swift-metrics"), 34 | .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), 35 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), 36 | ] 37 | ), 38 | .testTarget( 39 | name: "SystemMetricsTests", 40 | dependencies: [ 41 | "SystemMetrics", 42 | .product(name: "MetricsTestKit", package: "swift-metrics"), 43 | ] 44 | ), 45 | ] 46 | ) 47 | 48 | for target in package.targets 49 | where [.executable, .test, .regular].contains( 50 | target.type 51 | ) { 52 | var settings = target.swiftSettings ?? [] 53 | 54 | // https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md 55 | // Require `any` for existential types. 56 | settings.append(.enableUpcomingFeature("ExistentialAny")) 57 | 58 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md 59 | settings.append(.enableUpcomingFeature("MemberImportVisibility")) 60 | 61 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0409-access-level-on-imports.md 62 | settings.append(.enableUpcomingFeature("InternalImportsByDefault")) 63 | 64 | // https://docs.swift.org/compiler/documentation/diagnostics/nonisolated-nonsending-by-default/ 65 | settings.append(.enableUpcomingFeature("NonisolatedNonsendingByDefault")) 66 | 67 | // Ensure all public types are explicitly annotated as Sendable or not Sendable. 68 | settings.append(.unsafeFlags(["-Xfrontend", "-require-explicit-sendable"])) 69 | 70 | target.swiftSettings = settings 71 | } 72 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/Docs.docc/index.md: -------------------------------------------------------------------------------- 1 | # ``SystemMetrics`` 2 | 3 | Collect and report process-level system metrics in your application. 4 | 5 | ## Overview 6 | 7 | ``SystemMetricsMonitor`` automatically collects key process metrics and reports them through the Swift Metrics API. 8 | 9 | ### Available metrics 10 | 11 | The following metrics are collected: 12 | 13 | - **Virtual Memory**: Total virtual memory allocated by the process (in bytes), reported as `process_virtual_memory_bytes`. 14 | - **Resident Memory**: Physical memory currently used by the process (in bytes), reported as `process_resident_memory_bytes`. 15 | - **Start Time**: Process start time since Unix epoch (in seconds), reported as `process_start_time_seconds`. 16 | - **CPU Time**: Cumulative CPU time consumed (in seconds), reported as `process_cpu_seconds_total`. 17 | - **Max File Descriptors**: Maximum number of file descriptors the process can open, reported as `process_max_fds`. 18 | - **Open File Descriptors**: Number of file descriptors currently open, reported as `process_open_fds`. 19 | 20 | > Note: These metrics are currently implemented on Linux and macOS platforms only. 21 | 22 | ## Getting started 23 | 24 | ### Basic usage 25 | 26 | After adding `swift-metrics-extras` as a dependency, import the `SystemMetrics` module: 27 | 28 | ```swift 29 | import SystemMetrics 30 | ``` 31 | 32 | Create and start a monitor with default settings and a logger: 33 | 34 | ```swift 35 | // Import and create a logger, or use one of the existing loggers 36 | import Logging 37 | let logger = Logger(label: "MyService") 38 | 39 | // Create the monitor 40 | let monitor = SystemMetricsMonitor(logger: logger) 41 | 42 | // Create the service 43 | let serviceGroup = ServiceGroup( 44 | services: [monitor], 45 | gracefulShutdownSignals: [.sigint], 46 | cancellationSignals: [.sigterm], 47 | logger: logger 48 | ) 49 | 50 | // Start collecting metrics 51 | try await serviceGroup.run() 52 | ``` 53 | 54 | The monitor will collect and report metrics periodically using the global `MetricsSystem`. 55 | 56 | ## Configuration 57 | 58 | Polling interval can be configured through the ``SystemMetricsMonitor/Configuration``: 59 | 60 | ```swift 61 | let systemMetricsMonitor = SystemMetricsMonitor( 62 | configuration: .init(pollInterval: .seconds(5)), 63 | logger: logger 64 | ) 65 | ``` 66 | 67 | ## Using custom Metrics Factory 68 | 69 | ``SystemMetricsMonitor`` can be initialized with a specific metrics factory, so it does not rely on the global `MetricsSystem`: 70 | 71 | ```swift 72 | let monitor = SystemMetricsMonitor(metricsFactory: myPrometheusMetricsFactory, logger: logger) 73 | ``` 74 | 75 | ## Swift Service Lifecycle integration 76 | 77 | [Swift Service Lifecycle](https://github.com/swift-server/swift-service-lifecycle) provides a convenient way to manage background service tasks, which is compatible with the `SystemMetricsMonitor`: 78 | 79 | ```swift 80 | import SystemMetrics 81 | import ServiceLifecycle 82 | import UnixSignals 83 | import Metrics 84 | 85 | @main 86 | struct Application { 87 | static func main() async throws { 88 | let logger = Logger(label: "Application") 89 | let metrics = MyMetricsBackendImplementation() 90 | MetricsSystem.bootstrap(metrics) 91 | 92 | let service = FooService() 93 | let systemMetricsMonitor = SystemMetricsMonitor(logger: logger) 94 | 95 | let serviceGroup = ServiceGroup( 96 | services: [service, systemMetricsMonitor], 97 | gracefulShutdownSignals: [.sigint], 98 | cancellationSignals: [.sigterm], 99 | logger: logger 100 | ) 101 | 102 | try await serviceGroup.run() 103 | } 104 | } 105 | ``` 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Legal 2 | 3 | By submitting a pull request, you represent that you have the right to license 4 | your contribution to Apple and the community, and agree by submitting the patch 5 | that your contributions are licensed under the Apache 2.0 license (see 6 | `LICENSE.txt`). 7 | 8 | ## How to submit a bug report 9 | 10 | Please ensure to specify the following: 11 | 12 | * SwiftMetrics commit hash 13 | * Contextual information (e.g. what you were trying to achieve with SwiftMetrics) 14 | * Simplest possible steps to reproduce 15 | * More complex the steps are, lower the priority will be. 16 | * A pull request with failing test case is preferred, but it's just fine to paste the test case into the issue description. 17 | * Anything that might be relevant in your opinion, such as: 18 | * Swift version or the output of `swift --version` 19 | * OS version and the output of `uname -a` 20 | * Network configuration 21 | 22 | ### Example 23 | 24 | ``` 25 | SwiftMetrics commit hash: b17a8a9f0f814c01a56977680cb68d8a779c951f 26 | 27 | Context: 28 | While testing my application that uses with SwiftMetrics, I noticed that ... 29 | 30 | Steps to reproduce: 31 | 1. ... 32 | 2. ... 33 | 3. ... 34 | 4. ... 35 | 36 | $ swift --version 37 | Swift version 4.0.2 (swift-4.0.2-RELEASE) 38 | Target: x86_64-unknown-linux-gnu 39 | 40 | Operating system: Ubuntu Linux 16.04 64-bit 41 | 42 | $ uname -a 43 | Linux beefy.machine 4.4.0-101-generic #124-Ubuntu SMP Fri Nov 10 18:29:59 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux 44 | 45 | My system has IPv6 disabled. 46 | ``` 47 | 48 | ## Writing a Patch 49 | 50 | For non-trivial changes that affect the public API, it is good practice to have a discussion phase before writing code. Please follow the [proposal process](Sources/SystemMetrics/Docs.docc/Proposals/Proposals.md) to gather feedback and align on the approach with other contributors. 51 | 52 | A good SwiftMetrics patch is: 53 | 54 | 1. Concise, and contains as few changes as needed to achieve the end result. 55 | 2. Tested, ensuring that any tests provided failed before the patch and pass after it. 56 | 3. Documented, adding API documentation as needed to cover new functions and properties. 57 | 4. Accompanied by a great commit message, using our commit message template. 58 | 59 | ### Commit Message Template 60 | 61 | We require that your commit messages match our template. The easiest way to do that is to get git to help you by explicitly using the template. To do that, `cd` to the root of our repository and run: 62 | 63 | git config commit.template dev/git.commit.template 64 | 65 | ### Run CI checks locally 66 | 67 | You can run the Github Actions workflows locally using 68 | [act](https://github.com/nektos/act). To run all the jobs that run on a pull 69 | request, use the following command: 70 | 71 | ``` 72 | % act pull_request 73 | ``` 74 | 75 | To run just a single job, use `workflow_call -j `, and specify the inputs 76 | the job expects. For example, to run just shellcheck: 77 | 78 | ``` 79 | % act workflow_call -j soundness --input shell_check_enabled=true 80 | ``` 81 | 82 | To bind-mount the working directory to the container, rather than a copy, use 83 | `--bind`. For example, to run just the formatting, and have the results 84 | reflected in your working directory: 85 | 86 | ``` 87 | % act --bind workflow_call -j soundness --input format_check_enabled=true 88 | ``` 89 | 90 | If you'd like `act` to always run with certain flags, these can be be placed in 91 | an `.actrc` file either in the current working directory or your home 92 | directory, for example: 93 | 94 | ``` 95 | --container-architecture=linux/amd64 96 | --remote-name upstream 97 | --action-offline-mode 98 | ``` 99 | 100 | ## How to contribute your work 101 | 102 | Please open a pull request at https://github.com/apple/swift-metrics-extras. Make sure the CI passes, and then wait for code review. 103 | -------------------------------------------------------------------------------- /Tests/SystemMetricsTests/LinuxDataProviderTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | #if os(Linux) 16 | import Foundation 17 | import Testing 18 | import Glibc 19 | 20 | import SystemMetrics 21 | 22 | @Suite("Linux Data Provider Tests") 23 | struct LinuxDataProviderTests { 24 | @Test("Linux system metrics generation provides all required metrics") 25 | func systemMetricsGeneration() async throws { 26 | let _metrics = SystemMetricsMonitorDataProvider.linuxSystemMetrics() 27 | #expect(_metrics != nil) 28 | let metrics = _metrics! 29 | #expect(metrics.virtualMemoryBytes != 0) 30 | #expect(metrics.residentMemoryBytes != 0) 31 | #expect(metrics.startTimeSeconds != 0) 32 | #expect(metrics.maxFileDescriptors != 0) 33 | #expect(metrics.openFileDescriptors != 0) 34 | } 35 | 36 | @Test("Resident memory bytes reflects actual allocations") 37 | func residentMemoryBytes() throws { 38 | let pageByteCount = sysconf(Int32(_SC_PAGESIZE)) 39 | let allocationSize = 10_000 * pageByteCount 40 | 41 | let warmups = 20 42 | for _ in 0.. 0) 98 | } 99 | 100 | @Test("Data provider returns valid metrics via protocol") 101 | func dataProviderProtocol() async throws { 102 | let labels = SystemMetricsMonitor.Configuration.Labels( 103 | prefix: "test_", 104 | virtualMemoryBytes: "vmb", 105 | residentMemoryBytes: "rmb", 106 | startTimeSeconds: "sts", 107 | cpuSecondsTotal: "cpt", 108 | maxFileDescriptors: "mfd", 109 | openFileDescriptors: "ofd" 110 | ) 111 | let configuration = SystemMetricsMonitor.Configuration( 112 | pollInterval: .seconds(1), 113 | labels: labels 114 | ) 115 | 116 | let provider = SystemMetricsMonitorDataProvider(configuration: configuration) 117 | let data = await provider.data() 118 | 119 | #expect(data != nil) 120 | let metrics = data! 121 | #expect(metrics.virtualMemoryBytes > 0) 122 | #expect(metrics.residentMemoryBytes > 0) 123 | #expect(metrics.startTimeSeconds > 0) 124 | #expect(metrics.maxFileDescriptors > 0) 125 | #expect(metrics.openFileDescriptors > 0) 126 | } 127 | } 128 | #endif 129 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/SystemMetricsMonitorConfiguration.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | extension SystemMetricsMonitor { 16 | /// This object controls the behaviour of the ``SystemMetricsMonitor``. 17 | public struct Configuration: Sendable { 18 | /// Default SystemMetricsMonitor configuration. 19 | /// 20 | /// See individual property documentation for specific default values. 21 | public static let `default`: Self = .init() 22 | 23 | /// Interval between system metrics data scrapping 24 | public var interval: Duration 25 | 26 | /// String labels associated with the metrics 27 | package let labels: SystemMetricsMonitor.Configuration.Labels 28 | 29 | /// Additional dimensions attached to every metric 30 | package let dimensions: [(String, String)] 31 | 32 | /// Create new instance of ``Configuration`` 33 | /// 34 | /// - Parameters: 35 | /// - interval: The interval at which system metrics should be updated. 36 | public init( 37 | pollInterval interval: Duration = .seconds(2) 38 | ) { 39 | self.interval = interval 40 | self.labels = .init() 41 | self.dimensions = [] 42 | } 43 | 44 | /// Create new instance of ``SystemMetricsMonitor.Configuration`` 45 | /// 46 | /// - Parameters: 47 | /// - interval: The interval at which system metrics should be updated. 48 | /// - labels: The labels to use for generated system metrics. 49 | /// - dimensions: The dimensions to include in generated system metrics. 50 | package init( 51 | pollInterval interval: Duration = .seconds(2), 52 | labels: Labels, 53 | dimensions: [(String, String)] = [] 54 | ) { 55 | self.interval = interval 56 | self.labels = labels 57 | self.dimensions = dimensions 58 | } 59 | } 60 | } 61 | 62 | extension SystemMetricsMonitor.Configuration { 63 | /// Labels for the reported System Metrics Data. 64 | /// 65 | /// Backend implementations are encouraged to provide a static extension with 66 | /// defaults that suit their specific backend needs. 67 | package struct Labels: Sendable { 68 | /// Prefix to prefix all other labels with. 69 | package var prefix: String = "process_" 70 | /// Label for virtual memory size in bytes. 71 | package var virtualMemoryBytes: String = "virtual_memory_bytes" 72 | /// Label for resident memory size in bytes. 73 | package var residentMemoryBytes: String = "resident_memory_bytes" 74 | /// Label for process start time since Unix epoch in seconds. 75 | package var startTimeSeconds: String = "start_time_seconds" 76 | /// Label for total user and system CPU time spent in seconds. 77 | package var cpuSecondsTotal: String = "cpu_seconds_total" 78 | /// Label for maximum number of open file descriptors. 79 | package var maxFileDescriptors: String = "max_fds" 80 | /// Label for number of open file descriptors. 81 | package var openFileDescriptors: String = "open_fds" 82 | 83 | /// Construct a label for a metric as a concatenation of prefix and the corresponding label. 84 | /// 85 | /// - Parameters: 86 | /// - for: a property to construct the label for 87 | package func label(for keyPath: KeyPath) -> String { 88 | self.prefix + self[keyPath: keyPath] 89 | } 90 | 91 | /// Create a new `Labels` instance with default values. 92 | /// 93 | package init() { 94 | } 95 | 96 | /// Create a new `Labels` instance. 97 | /// 98 | /// - Parameters: 99 | /// - prefix: Prefix to prefix all other labels with. 100 | /// - virtualMemoryBytes: Label for virtual memory size in bytes 101 | /// - residentMemoryBytes: Label for resident memory size in bytes. 102 | /// - startTimeSeconds: Label for process start time since Unix epoch in seconds. 103 | /// - cpuSecondsTotal: Label for total user and system CPU time spent in seconds. 104 | /// - maxFileDescriptors: Label for maximum number of open file descriptors. 105 | /// - openFileDescriptors: Label for number of open file descriptors. 106 | package init( 107 | prefix: String, 108 | virtualMemoryBytes: String, 109 | residentMemoryBytes: String, 110 | startTimeSeconds: String, 111 | cpuSecondsTotal: String, 112 | maxFileDescriptors: String, 113 | openFileDescriptors: String 114 | ) { 115 | self.prefix = prefix 116 | self.virtualMemoryBytes = virtualMemoryBytes 117 | self.residentMemoryBytes = residentMemoryBytes 118 | self.startTimeSeconds = startTimeSeconds 119 | self.cpuSecondsTotal = cpuSecondsTotal 120 | self.maxFileDescriptors = maxFileDescriptors 121 | self.openFileDescriptors = openFileDescriptors 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/SystemMetricsMonitor+Darwin.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | #if canImport(Darwin) 16 | import Darwin 17 | 18 | extension SystemMetricsMonitorDataProvider: SystemMetricsProvider { 19 | /// Collect current system metrics data. 20 | /// 21 | /// On supported Darwin platforms, this delegates to the static 22 | /// `darwinSystemMetrics()` function. 23 | /// 24 | /// - Note: System metrics collection is not yet implemented for Darwin-based 25 | /// platforms other than macOS. This method always returns `nil` on 26 | /// other platforms. 27 | /// - Returns: Current system metrics, or `nil` if collection failed. 28 | package func data() async -> SystemMetricsMonitor.Data? { 29 | #if os(macOS) 30 | Self.darwinSystemMetrics() 31 | #else 32 | #warning("System metrics are not yet implemented for platforms other than macOS and Linux.") 33 | return nil 34 | #endif 35 | } 36 | 37 | #if os(macOS) 38 | /// Collect system metrics data on Darwin hosts. 39 | /// 40 | /// This function reads process statistics using system APIs to produce 41 | /// a complete snapshot of the process's resource usage. 42 | /// 43 | /// - Returns: Collected metrics, or `nil` if collection failed. 44 | package static func darwinSystemMetrics() -> SystemMetricsMonitor.Data? { 45 | guard let taskInfo = ProcessTaskInfo.snapshot() else { return nil } 46 | guard let fileCounts = FileDescriptorCounts.snapshot() else { return nil } 47 | 48 | // Memory consumption 49 | let virtualMemoryBytes = Int(taskInfo.ptinfo.pti_virtual_size) 50 | let residentMemoryBytes = Int(taskInfo.ptinfo.pti_resident_size) 51 | 52 | // CPU time 53 | let userMachTicks = taskInfo.ptinfo.pti_total_user 54 | let systemMachTicks = taskInfo.ptinfo.pti_total_system 55 | 56 | let cpuTimeSeconds: Double = { 57 | // Mach ticks need to be converted to nanoseconds using mach_timebase_info 58 | let totalUserTime = Double(userMachTicks) * Self.machTimebaseRatio 59 | let totalSystemTime = Double(systemMachTicks) * Self.machTimebaseRatio 60 | let cpuTimeNanoseconds = totalUserTime + totalSystemTime 61 | return cpuTimeNanoseconds / Double(NSEC_PER_SEC) 62 | }() 63 | 64 | return .init( 65 | virtualMemoryBytes: virtualMemoryBytes, 66 | residentMemoryBytes: residentMemoryBytes, 67 | startTimeSeconds: Int(taskInfo.pbsd.pbi_start_tvsec), 68 | cpuSeconds: cpuTimeSeconds, 69 | maxFileDescriptors: fileCounts.maximum, 70 | openFileDescriptors: fileCounts.open 71 | ) 72 | } 73 | 74 | /// Ratio for converting Mach absolute time ticks to nanoseconds. 75 | private static let machTimebaseRatio: Double = { 76 | var info = mach_timebase_info_data_t() 77 | let result = mach_timebase_info(&info) 78 | // This is not expected to fail under any normal condition. 79 | precondition(result == KERN_SUCCESS, "mach_timebase_info failed") 80 | return Double(info.numer) / Double(info.denom) 81 | }() 82 | 83 | /// File descriptor counts for the current process. 84 | /// 85 | /// This struct holds both the current number of open file descriptors 86 | /// and the maximum number allowed by the system limit. 87 | private struct FileDescriptorCounts { 88 | /// The current number of open file descriptors. 89 | var open: Int 90 | 91 | /// The configured maximum number of file descriptors. 92 | var maximum: Int 93 | 94 | /// Collect current file descriptor counts. 95 | /// 96 | /// - Returns: File descriptor counts, or `nil` if collection failed. 97 | static func snapshot() -> Self? { 98 | let open: Int? = { 99 | // First, get the required buffer size 100 | let bufferSize = proc_pidinfo(getpid(), PROC_PIDLISTFDS, 0, nil, 0) 101 | guard bufferSize > 0 else { return nil } 102 | 103 | let FDInfoLayout = MemoryLayout.self 104 | 105 | // Next, use a temporary buffer to retrieve the real 106 | // count of open files. 107 | let usedSize = withUnsafeTemporaryAllocation( 108 | byteCount: Int(bufferSize), 109 | alignment: FDInfoLayout.alignment 110 | ) { rawBuffer in 111 | proc_pidinfo( 112 | getpid(), 113 | PROC_PIDLISTFDS, 114 | 0, 115 | rawBuffer.baseAddress, 116 | bufferSize 117 | ) 118 | } 119 | 120 | guard usedSize > 0 else { return nil } 121 | return Int(usedSize) / FDInfoLayout.size 122 | }() 123 | 124 | let maximum: Int? = { 125 | var descriptorLimit = rlimit() 126 | let result = getrlimit(RLIMIT_NOFILE, &descriptorLimit) 127 | guard result == 0 else { return nil } 128 | return Int(descriptorLimit.rlim_cur) 129 | }() 130 | 131 | guard let open, let maximum else { return nil } 132 | return Self(open: open, maximum: maximum) 133 | } 134 | } 135 | 136 | /// Namespace for process task information retrieval. 137 | private enum ProcessTaskInfo { 138 | /// Collect current process task information. 139 | /// 140 | /// - Returns: Process task information, or `nil` if collection failed. 141 | static func snapshot() -> proc_taskallinfo? { 142 | var info = proc_taskallinfo() 143 | let size = Int32(MemoryLayout.size) 144 | let result = proc_pidinfo(getpid(), PROC_PIDTASKALLINFO, 0, &info, size) 145 | guard result == size else { return nil } 146 | return info 147 | } 148 | } 149 | #endif 150 | } 151 | #endif 152 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/Docs.docc/Proposals/SSM-0001.md: -------------------------------------------------------------------------------- 1 | # SSM-0001: `swift-metrics-extras` revised public API to support 1.0 roadmap 2 | 3 | As part of the 1.0 release we would like to revise the public API to simplify adoption and maintenance: 4 | * Reduce public API to a minimum to make it easier to support more platforms and metrics gathering strategies. 5 | * Decouple `swift-metrics-extras` public interface from the global `MetricsSystem`. 6 | * Add compatibility with `swift-service-lifecycle` 7 | 8 | ## Overview 9 | 10 | - Proposal: SSM-0001 11 | - Author(s): [Vladimir Kukushkin](https://github.com/kukushechkin) 12 | - Status: **Approved** 13 | - Issue: [apple/swift-metrics-extras#64](https://github.com/apple/swift-metrics-extras/issues/64) 14 | - Implementation: 15 | - [apple/swift-metrics-extras#66](https://github.com/apple/swift-metrics-extras/pull/66) 16 | - Related links: 17 | - [Lightweight proposals process description](https://github.com/apple/swift-metrics-extras/blob/main/Sources/SystemMetrics/Docs.docc/Proposals/Proposals.md) 18 | 19 | ### Introduction 20 | 21 | This proposal aims to change the public interface of the package to bring it in line with the latest best practices in the Swift ecosystem and reduce the API to a minimum focused on automatic background gathering of system metrics for the supported platforms. 22 | 23 | ### Motivation 24 | 25 | `swift-metrics-extras` provides only one component — `SystemMetrics`, which periodically collects process system metrics in the background. The monitoring is a separate component, which depends on the `MetricsSystem`, but is not part of it. While it is possible to use `SystemMetrics` as an independent object, the user would need to maintain its lifecycle manually, and it still requires the globally initialized `MetricsSystem`. 26 | Coupling with the `MetricsSystem` bootstrapping is unnecessary and prevents users from using different backends for different metrics, for example, if `SystemMetrics` should be sent to a different backend. This coupling also makes it hard to unit test the package. 27 | 28 | ### Proposed solution 29 | 30 | To solve the unnecessary coupling with the `MetricsSystem`, I propose to change the interface of the `SystemMetrics` — a new `SystemMetricsMonitor` object compatible with the `swift-service-lifecycle` `Service` protocol to run the periodic metrics collection and reporting in the background for the whole lifecycle of the parent service. 31 | 32 | This is how it can be integrated into a service: 33 | 34 | ``` 35 | import SystemMetrics 36 | import ServiceLifecycle 37 | import UnixSignals 38 | import Metrics 39 | 40 | @main 41 | struct Application { 42 | static func main() async throws { 43 | let logger = Logger(label: "Application") 44 | let metrics = MyMetricsBackendImplementation() 45 | MetricsSystem.bootstrap(metrics) 46 | 47 | let service = FooService() 48 | let systemMetricsMonitor = SystemMetricsMonitor() 49 | let serviceGroup = ServiceGroup( 50 | services: [service, systemMetricsMonitor], 51 | gracefulShutdownSignals: [.sigint], 52 | cancellationSignals: [.sigterm], 53 | logger: logger 54 | ) 55 | 56 | try await serviceGroup.run() 57 | } 58 | } 59 | ``` 60 | 61 | The proposed interface also makes the package more testable, as users can inject a mock `MetricsFactory` and verify metric collection without global state dependency. 62 | 63 | ### Detailed design 64 | 65 | The full API of the proposed `SystemMetricsMonitor` is the following: 66 | 67 | ``` 68 | /// A monitor that periodically collects and reports system metrics. 69 | /// 70 | /// ``SystemMetricsMonitor`` provides a way to automatically collect process-level system metrics 71 | /// (such as memory usage, CPU time) and report them through the Swift Metrics API. 72 | /// 73 | /// Example usage: 74 | /// ```swift 75 | /// let logger = Logger(label: "My logger") 76 | /// let monitor = SystemMetricsMonitor(logger: logger) 77 | /// try await monitor.run() 78 | /// ``` 79 | public struct SystemMetricsMonitor: Service { 80 | /// Create a new ``SystemMetricsMonitor`` with a custom metrics factory. 81 | /// 82 | /// - Parameters: 83 | /// - configuration: The configuration for the monitor. 84 | /// - metricsFactory: The metrics factory to use for creating metrics. 85 | /// - logger: A custom logger. 86 | public init( 87 | configuration: SystemMetricsMonitor.Configuration = .default, 88 | metricsFactory: any MetricsFactory, 89 | logger: Logger 90 | ) 91 | 92 | /// Create a new ``SystemMetricsMonitor`` using the global metrics factory. 93 | /// 94 | /// - Parameters: 95 | /// - configuration: The configuration for the monitor. 96 | /// - logger: A custom logger. 97 | public init( 98 | configuration: SystemMetricsMonitor.Configuration = .default, 99 | logger: Logger 100 | ) 101 | 102 | /// Start the monitoring loop, collecting and reporting metrics at the configured interval. 103 | /// 104 | /// This method runs indefinitely, periodically collecting and reporting system metrics 105 | /// according to the poll interval specified in the configuration. It will only return 106 | /// if the async task is cancelled. 107 | public func run() async throws 108 | } 109 | ``` 110 | 111 | With configuration represented by the structure `SystemMetricsMonitor.Configuration`: 112 | 113 | ``` 114 | extension SystemMetricsMonitor { 115 | /// This object controls the behaviour of the ``SystemMetricsMonitor``. 116 | public struct Configuration: Sendable { 117 | /// Default ``SystemMetricsMonitor`` configuration. 118 | /// 119 | /// See individual property documentation for specific default values. 120 | public static let `default`: Self = .init() 121 | 122 | /// Interval between system metrics data scrapping 123 | public var interval: Duration 124 | 125 | /// Create new instance of ``Configuration`` 126 | /// 127 | /// - Parameters: 128 | /// - interval: The interval at which system metrics should be updated. 129 | public init( 130 | pollInterval interval: Duration = .seconds(2) 131 | ) { 132 | self.interval = interval 133 | self.labels = .init() 134 | self.dimensions = [] 135 | } 136 | } 137 | } 138 | ``` 139 | 140 | ### API stability 141 | 142 | This is a breaking change in the public interface, which is acceptable as the package is currently pre-1.0. 143 | 144 | ### Future directions 145 | 146 | Darwin and Windows support. As unit tests are now also decoupled from the global state, there is a clear way of testing platform-specific functionality when a new platform support is introduced. 147 | 148 | In the future, it might be valuable to expose the internal `SystemMetricsProvider` to allow users to create custom implementations for collecting system metrics. 149 | 150 | The package internals can be expanded to support multiple data providers called at different intervals to gather metrics more efficiently. 151 | 152 | Expand the list of collected metrics, for example, following https://prometheus.io/docs/instrumenting/writing_clientlibs/#process-metrics and https://prometheus.io/docs/instrumenting/writing_clientlibs/#runtime-metrics. 153 | 154 | ### Alternatives considered 155 | 156 | **Keep the public API and implementation as is.** In this case, we keep the public API intact, but rely on the global `MetricsSystem` bootstrapping. 157 | 158 | **Keep all the existing customization options.** This would potentially require workarounds if new platforms, metrics or backends implementations do not support the existing model. Removed customization features like custom labels or dimensions can be added post-1.0 if this is considered necessary. 159 | -------------------------------------------------------------------------------- /Tests/SystemMetricsTests/DarwinDataProviderTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | #if os(macOS) 16 | import Foundation 17 | import Testing 18 | import Darwin 19 | 20 | import SystemMetrics 21 | 22 | // This suite is serialized, as the tests involve exercising and 23 | // measuring process metrics, so running them concurrently can 24 | // make measurements flaky. 25 | 26 | @Suite("Darwin Data Provider Tests", .serialized) 27 | struct DarwinDataProviderTests { 28 | 29 | // MARK: - Test Cases 30 | 31 | @Test("Darwin system metrics generation provides all required metrics") 32 | func systemMetricsGeneration() throws { 33 | let metrics = try readMetrics() 34 | #expect(metrics.virtualMemoryBytes != 0) 35 | #expect(metrics.residentMemoryBytes != 0) 36 | #expect(metrics.startTimeSeconds != 0) 37 | #expect(metrics.maxFileDescriptors != 0) 38 | #expect(metrics.openFileDescriptors != 0) 39 | } 40 | 41 | @Test("Resident memory size reflects allocations") 42 | func residentMemory() throws { 43 | let allocationSize = 10_000 * pageSize 44 | 45 | let bytesBefore = try readMetric(\.residentMemoryBytes) 46 | 47 | var address = try #require(vmAlloc(allocationSize)) 48 | defer { vmFree(&address, size: allocationSize) } 49 | 50 | // In order for the memory to become resident, we need to access it 51 | memset(UnsafeMutableRawPointer(bitPattern: address), 0xA2, Int(allocationSize)) 52 | 53 | let bytesDuring = try readMetric(\.residentMemoryBytes) 54 | #expect(bytesDuring > bytesBefore) 55 | 56 | vmFree(&address, size: allocationSize) 57 | #expect(address == 0) 58 | 59 | let bytesAfter = try readMetric(\.residentMemoryBytes) 60 | #expect(bytesAfter < bytesDuring) 61 | 62 | // Within 10% of original 63 | #expect(bytesAfter <= Int(Double(bytesBefore) * 1.1)) 64 | } 65 | 66 | @Test("CPU seconds measurement reflects actual CPU usage") 67 | func cpuSeconds() throws { 68 | let cpuSecondsBefore = try readMetric(\.cpuSeconds) 69 | burnCPUSeconds(1) 70 | let cpuSecondsAfter = try readMetric(\.cpuSeconds) 71 | 72 | // We can only set expectations for the lower limit for the CPU usage time, 73 | // other threads executing other tests can add more CPU usage 74 | #expect((cpuSecondsAfter - cpuSecondsBefore) > 0.1) 75 | } 76 | 77 | @Test("CPU time is accurate, compared to an alternate API") 78 | func cpuSecondsMatchAlternative() throws { 79 | // This test collects `cpuSeconds` using an alternate API 80 | // and compares it to the values provided in the metrics data. 81 | 82 | // No need to measure the "before" state - that's covered in another test. 83 | burnCPUSeconds(1) 84 | 85 | var usage = rusage() 86 | getrusage(RUSAGE_SELF, &usage) 87 | 88 | let measuredCPU = try readMetric(\.cpuSeconds) 89 | 90 | func secondsFromComponents(_ seconds: Int, _ microseconds: Int32) -> Double { 91 | Double(seconds) + Double(microseconds) / Double(USEC_PER_SEC) 92 | } 93 | 94 | let expectedUserCPU = secondsFromComponents(usage.ru_utime.tv_sec, usage.ru_utime.tv_usec) 95 | let expectedSystemCPU = secondsFromComponents(usage.ru_stime.tv_sec, usage.ru_stime.tv_usec) 96 | let expectedCPU = expectedUserCPU + expectedSystemCPU 97 | 98 | #expect(abs(expectedCPU - measuredCPU) < 0.1) 99 | } 100 | 101 | @Test("Process start time is accurate, compared to an alternate API") 102 | func processStartMatchesAlternative() throws { 103 | // This test collects `startTimeSeconds` using an alternate API 104 | // and compares it to the value provided in the metrics data. 105 | 106 | var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] 107 | var proc = kinfo_proc() 108 | var size = MemoryLayout.size 109 | let result = sysctl(&mib, UInt32(mib.count), &proc, &size, nil, 0) 110 | guard result == 0 else { 111 | Issue.record("sysctl failed") 112 | return 113 | } 114 | 115 | let expectedStartTime = proc.kp_proc.p_starttime.tv_sec 116 | let metricsStartTime = try readMetric(\.startTimeSeconds) 117 | 118 | // We're ignoring sub-second precision in the test-generated 119 | // timestamp, so allow for the metrics data to round up or down. 120 | #expect(abs(metricsStartTime - expectedStartTime) <= 1) 121 | } 122 | 123 | @Test("File descriptor counts are accurate") 124 | func openFileDescriptors() throws { 125 | let openBefore = try readMetric(\.openFileDescriptors) 126 | 127 | let fd = open("/dev/null", O_RDONLY) 128 | guard fd >= 0 else { 129 | Issue.record("Failed to open /dev/null") 130 | return 131 | } 132 | defer { close(fd) } 133 | 134 | let openDuring = try readMetric(\.openFileDescriptors) 135 | #expect(openDuring == openBefore + 1) 136 | } 137 | 138 | @Test("Data provider returns valid metrics via protocol") 139 | func dataProviderProtocol() async throws { 140 | let labels = SystemMetricsMonitor.Configuration.Labels( 141 | prefix: "test_", 142 | virtualMemoryBytes: "vmb", 143 | residentMemoryBytes: "rmb", 144 | startTimeSeconds: "sts", 145 | cpuSecondsTotal: "cpt", 146 | maxFileDescriptors: "mfd", 147 | openFileDescriptors: "ofd" 148 | ) 149 | let configuration = SystemMetricsMonitor.Configuration( 150 | pollInterval: .seconds(1), 151 | labels: labels 152 | ) 153 | 154 | let provider = SystemMetricsMonitorDataProvider(configuration: configuration) 155 | let data = await provider.data() 156 | let metrics = try #require(data) 157 | #expect(metrics.virtualMemoryBytes > 0) 158 | #expect(metrics.residentMemoryBytes > 0) 159 | #expect(metrics.startTimeSeconds > 0) 160 | #expect(metrics.maxFileDescriptors > 0) 161 | #expect(metrics.openFileDescriptors > 0) 162 | } 163 | 164 | // MARK: - Helpers 165 | 166 | private let pageSize = sysconf(Int32(_SC_PAGESIZE)) 167 | 168 | /// Reads the current system metrics snapshot. 169 | /// 170 | /// - Returns: The current system metrics data. 171 | /// - Throws: If metrics collection fails or returns `nil`. 172 | private func readMetrics() throws -> SystemMetricsMonitor.Data { 173 | try #require(SystemMetricsMonitorDataProvider.darwinSystemMetrics()) 174 | } 175 | 176 | /// Reads a specific metric value for the given key path. 177 | /// 178 | /// - Parameter keyPath: The key path to the desired metric value. 179 | /// - Returns: The value of the specified metric. 180 | /// - Throws: If metrics collection fails. 181 | private func readMetric( 182 | _ keyPath: KeyPath 183 | ) throws -> Result { 184 | let metrics = try readMetrics() 185 | return metrics[keyPath: keyPath] 186 | } 187 | 188 | /// Performs CPU-intensive work for the specified duration. 189 | /// 190 | /// - Parameter seconds: Wall-clock time to spend consuming CPU. 191 | private func burnCPUSeconds(_ seconds: TimeInterval) { 192 | let bytes = Array(repeating: UInt8.zero, count: 10) 193 | var hasher = Hasher() 194 | 195 | let startTime = Date() 196 | while Date().timeIntervalSince(startTime) < seconds { 197 | bytes.hash(into: &hasher) 198 | } 199 | } 200 | 201 | /// Allocates virtual memory using `vm_allocate`. 202 | /// 203 | /// These tests use the `vm_allocate`/`vm_deallocate` functions rather than 204 | /// Swift's `UnsafeMutablePointer.allocate(_:)` family because they make it 205 | /// easier to observe memory metrics changing when allocations are freed. 206 | /// 207 | /// - Parameter size: The number of bytes to allocate. 208 | /// - Returns: The allocated address, or `nil` if allocation failed. 209 | private func vmAlloc(_ size: Int) -> vm_address_t? { 210 | var address: vm_address_t = 0 211 | let result = vm_allocate(mach_task_self_, &address, vm_size_t(size), VM_FLAGS_ANYWHERE) 212 | guard result == KERN_SUCCESS else { 213 | Issue.record("vm_allocate failed") 214 | return nil 215 | } 216 | precondition(address > 0) 217 | return address 218 | } 219 | 220 | /// Deallocates virtual memory allocated with `vmAlloc(_:)`. 221 | /// 222 | /// This function is idempotent - calling it with the same inout 223 | /// reference multiple times is safe, as the address is zeroed 224 | /// upon deallocation. 225 | /// 226 | /// - Parameters: 227 | /// - address: The address to deallocate (will be set to 0). 228 | /// - size: The size of the allocation in bytes. 229 | private func vmFree(_ address: inout vm_address_t, size: Int) { 230 | let toFree = address 231 | address = 0 232 | guard toFree != 0 else { return } 233 | vm_deallocate(mach_task_self_, toFree, vm_size_t(size)) 234 | } 235 | } 236 | #endif 237 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/SystemMetricsMonitor+Linux.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | import CoreMetrics 15 | 16 | #if canImport(Glibc) 17 | import Glibc 18 | #elseif canImport(Musl) 19 | import Musl 20 | #endif 21 | 22 | #if canImport(Glibc) || canImport(Musl) 23 | extension SystemMetricsMonitorDataProvider: SystemMetricsProvider { 24 | /// Minimal file reading implementation so we don't have to depend on Foundation. 25 | /// Designed only for the narrow use case of this library. 26 | final class CFile { 27 | let path: String 28 | 29 | private var file: UnsafeMutablePointer? 30 | 31 | init(_ path: String) { 32 | self.path = path 33 | } 34 | 35 | deinit { 36 | assert(self.file == nil) 37 | } 38 | 39 | func open() { 40 | guard let f = fopen(path, "r") else { 41 | return 42 | } 43 | self.file = f 44 | } 45 | 46 | func close() { 47 | if let f = self.file { 48 | self.file = nil 49 | let success = fclose(f) == 0 50 | assert(success) 51 | } 52 | } 53 | 54 | func readLine() -> String? { 55 | guard let f = self.file else { 56 | return nil 57 | } 58 | return withUnsafeTemporaryAllocation(of: CChar.self, capacity: 1024) { (ptr) -> String? in 59 | let hasNewLine = { 60 | guard fgets(ptr.baseAddress, Int32(ptr.count), f) != nil else { 61 | // feof returns non-zero only when eof has been reached 62 | if feof(f) != 0 { 63 | return false 64 | } else { 65 | preconditionFailure("Error reading line") 66 | } 67 | } 68 | return true 69 | }() 70 | if !hasNewLine { 71 | return nil 72 | } 73 | // fgets return value has already been checked to be non-null 74 | // and it returns the pointer passed in as the first argument 75 | // this ensures at this point the ptr contains a valid C string 76 | // the initializer will copy the memory ensuring it doesn't 77 | // outlive the scope of withUnsafeTemporaryAllocation 78 | return String(cString: ptr.baseAddress!) 79 | } 80 | } 81 | 82 | func readFull() -> String { 83 | var s = "" 84 | func loop() -> String { 85 | if let l = readLine() { 86 | s += l 87 | return loop() 88 | } 89 | return s 90 | } 91 | return loop() 92 | } 93 | } 94 | 95 | private static let systemStartTimeInSecondsSinceEpoch: Int? = { 96 | // Read system boot time from /proc/stat btime field 97 | // This provides the Unix timestamp when the system was booted 98 | let systemStatFile = CFile("/proc/stat") 99 | systemStatFile.open() 100 | defer { 101 | systemStatFile.close() 102 | } 103 | while let line = systemStatFile.readLine() { 104 | if line.starts(with: "btime"), 105 | let systemUptimeInSecondsSinceEpochString = 106 | line 107 | .lazy 108 | .split(separator: " ") 109 | .last? 110 | .split(separator: "\n") 111 | .first, 112 | let systemUptimeInSecondsSinceEpoch = Int(String(systemUptimeInSecondsSinceEpochString)) 113 | { 114 | return systemUptimeInSecondsSinceEpoch 115 | } 116 | } 117 | return nil 118 | }() 119 | 120 | /// Collect current system metrics data. 121 | /// 122 | /// On Linux, this delegates to the static `linuxSystemMetrics()` function. 123 | /// 124 | /// - Returns: Current system metrics, or `nil` if collection failed. 125 | package func data() async -> SystemMetricsMonitor.Data? { 126 | Self.linuxSystemMetrics() 127 | } 128 | 129 | /// Collect system metrics data on Linux using multiple system APIs and interfaces. 130 | /// 131 | /// This function combines data from several Linux system interfaces to calculate 132 | /// process metrics: 133 | /// 134 | /// Data Sources: 135 | /// 136 | /// - `/proc/stat` - System boot time 137 | /// - `sysconf(_SC_CLK_TCK)` - System clock ticks per second for time conversion 138 | /// - `sysconf(_SC_PAGESIZE)` - System page size for memory conversion 139 | /// - `/proc/self/stat` - Process memory usage and start time 140 | /// - `getrusage(RUSAGE_SELF)` - CPU time 141 | /// - `getrlimit(RLIMIT_NOFILE)` - Maximum file descriptors limit 142 | /// - `/proc/self/fd/` directory enumeration - Count of open file descriptors 143 | /// 144 | /// - Returns: A `Data` struct containing all collected metrics, or `nil` if 145 | /// metrics could not be collected (e.g., due to file read errors). 146 | package static func linuxSystemMetrics() -> SystemMetricsMonitor.Data? { 147 | /// The current implementation below reads /proc/self/stat. Then, 148 | /// presumably to accommodate whitespace in the `comm` field 149 | /// without dealing with null-terminated C strings, it splits on the 150 | /// closing parenthesis surrounding the value. It then splits the 151 | /// remaining string by space, meaning the first element in the 152 | /// resulting array, at index 0, refers to the the third field. 153 | /// 154 | /// Note that the man page documents fields starting at index 1. 155 | /// 156 | /// ```` 157 | /// proc_pid_stat(5) File Formats Manual proc_pid_stat(5) 158 | /// 159 | /// ... 160 | /// 161 | /// (1) pid %d 162 | /// The process ID. 163 | /// 164 | /// (2) comm %s 165 | /// The filename of the executable, in parentheses. 166 | /// Strings longer than TASK_COMM_LEN (16) characters 167 | /// (including the terminating null byte) are silently 168 | /// truncated. This is visible whether or not the 169 | /// executable is swapped out. 170 | /// 171 | /// ... 172 | /// 173 | /// (22) starttime %llu 174 | /// The time the process started after system boot. 175 | /// Before Linux 2.6, this value was expressed in 176 | /// jiffies. Since Linux 2.6, the value is expressed in 177 | /// clock ticks (divide by sysconf(_SC_CLK_TCK)). 178 | /// 179 | /// The format for this field was %lu before Linux 2.6. 180 | /// 181 | /// (23) vsize %lu 182 | /// Virtual memory size in bytes. 183 | /// The format for this field was %lu before Linux 2.6. 184 | /// 185 | /// (24) rss %ld 186 | /// Resident Set Size: number of pages the process has 187 | /// in real memory. This is just the pages which count 188 | /// toward text, data, or stack space. This does not 189 | /// include pages which have not been demand-loaded in, 190 | /// or which are swapped out. This value is inaccurate; 191 | /// see /proc/pid/statm below. 192 | /// ``` 193 | enum StatIndices { 194 | static let virtualMemoryBytes = 20 195 | static let residentMemoryBytes = 21 196 | static let startTimeTicks = 19 197 | } 198 | 199 | /// Use sysconf to get system configuration values that do not change 200 | /// during the lifetime of the process. They are used later to convert 201 | /// ticks into seconds and memory pages into total bytes. 202 | enum SystemConfiguration { 203 | static let clockTicksPerSecond = sysconf(Int32(_SC_CLK_TCK)) 204 | static let pageByteCount = sysconf(Int32(_SC_PAGESIZE)) 205 | } 206 | 207 | let statFile = CFile("/proc/self/stat") 208 | statFile.open() 209 | defer { 210 | statFile.close() 211 | } 212 | 213 | // Read /proc/self/stat to get process memory and timing statistics 214 | let statFileContents = statFile.readFull() 215 | 216 | guard 217 | let statString = 218 | statFileContents 219 | .split(separator: ")") 220 | .last 221 | else { return nil } 222 | let stats = String(statString) 223 | .split(separator: " ") 224 | .map(String.init) 225 | guard 226 | let virtualMemoryBytes = Int(stats[StatIndices.virtualMemoryBytes]), 227 | let rss = Int(stats[StatIndices.residentMemoryBytes]), 228 | let startTimeTicks = Int(stats[StatIndices.startTimeTicks]) 229 | else { return nil } 230 | 231 | let residentMemoryBytes = rss * SystemConfiguration.pageByteCount 232 | let processStartTimeInSeconds = startTimeTicks / SystemConfiguration.clockTicksPerSecond 233 | 234 | // Use getrusage(RUSAGE_SELF) system call to get CPU time consumption 235 | // This provides both user and system time spent by this process 236 | var _rusage = rusage() 237 | guard 238 | withUnsafeMutablePointer( 239 | to: &_rusage, 240 | { ptr in 241 | #if canImport(Musl) 242 | getrusage(RUSAGE_SELF, ptr) == 0 243 | #else 244 | getrusage(__rusage_who_t(RUSAGE_SELF.rawValue), ptr) == 0 245 | #endif 246 | } 247 | ) 248 | else { return nil } 249 | let cpuSecondsUser: Double = Double(_rusage.ru_utime.tv_sec) + Double(_rusage.ru_utime.tv_usec) / 1_000_000.0 250 | let cpuSecondsSystem: Double = Double(_rusage.ru_stime.tv_sec) + Double(_rusage.ru_stime.tv_usec) / 1_000_000.0 251 | let cpuSecondsTotal: Double = cpuSecondsUser + cpuSecondsSystem 252 | 253 | guard let systemStartTimeInSecondsSinceEpoch = Self.systemStartTimeInSecondsSinceEpoch else { 254 | return nil 255 | } 256 | let startTimeInSecondsSinceEpoch = systemStartTimeInSecondsSinceEpoch + processStartTimeInSeconds 257 | 258 | // Use getrlimit(RLIMIT_NOFILE) system call to get file descriptor limits 259 | var _rlim = rlimit() 260 | guard 261 | withUnsafeMutablePointer( 262 | to: &_rlim, 263 | { ptr in 264 | #if canImport(Musl) 265 | getrlimit(RLIMIT_NOFILE, ptr) == 0 266 | #else 267 | getrlimit(__rlimit_resource_t(RLIMIT_NOFILE.rawValue), ptr) == 0 268 | #endif 269 | } 270 | ) 271 | else { return nil } 272 | 273 | let maxFileDescriptors = Int(_rlim.rlim_max) 274 | 275 | // Count open file descriptors by enumerating /proc/self/fd directory 276 | // Each entry represents an open file descriptor for this process 277 | guard let dir = opendir("/proc/self/fd") else { return nil } 278 | defer { 279 | closedir(dir) 280 | } 281 | var openFileDescriptors = 0 282 | while readdir(dir) != nil { openFileDescriptors += 1 } 283 | 284 | return .init( 285 | virtualMemoryBytes: virtualMemoryBytes, 286 | residentMemoryBytes: residentMemoryBytes, 287 | startTimeSeconds: startTimeInSecondsSinceEpoch, 288 | cpuSeconds: cpuSecondsTotal, 289 | maxFileDescriptors: maxFileDescriptors, 290 | openFileDescriptors: openFileDescriptors 291 | ) 292 | } 293 | } 294 | #endif 295 | -------------------------------------------------------------------------------- /Sources/SystemMetrics/SystemMetricsMonitor.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | import AsyncAlgorithms 15 | public import CoreMetrics 16 | import Foundation 17 | public import Logging 18 | public import ServiceLifecycle 19 | 20 | /// A monitor that periodically collects and reports system metrics. 21 | /// 22 | /// ``SystemMetricsMonitor`` provides a way to automatically collect process-level system metrics 23 | /// (such as memory usage, CPU time) and report them through the Swift Metrics API. 24 | /// 25 | /// Example usage: 26 | /// ```swift 27 | /// import SystemMetrics 28 | /// import Logging 29 | /// 30 | /// let logger = Logger(label: "MyService") 31 | /// let monitor = SystemMetricsMonitor(logger: logger) 32 | /// let serviceGroup = ServiceGroup( 33 | /// services: [monitor], 34 | /// gracefulShutdownSignals: [.sigint], 35 | /// cancellationSignals: [.sigterm], 36 | /// logger: logger 37 | /// ) 38 | /// try await serviceGroup.run() 39 | /// ``` 40 | public struct SystemMetricsMonitor: Service { 41 | /// Configuration for the system metrics monitor. 42 | let configuration: SystemMetricsMonitor.Configuration 43 | 44 | /// Optional metrics factory for testing. If nil, uses `MetricsSystem.factory`. 45 | let metricsFactory: (any MetricsFactory)? 46 | 47 | /// The provider responsible for collecting system metrics data. 48 | let dataProvider: any SystemMetricsProvider 49 | 50 | /// Internal logger 51 | let logger: Logger 52 | 53 | /// Pre-initialized gauges for metrics reporting 54 | let virtualMemoryBytesGauge: Gauge 55 | let residentMemoryBytesGauge: Gauge 56 | let startTimeSecondsGauge: Gauge 57 | let cpuSecondsTotalGauge: Gauge 58 | let maxFileDescriptorsGauge: Gauge 59 | let openFileDescriptorsGauge: Gauge 60 | 61 | /// Create a new ``SystemMetricsMonitor`` using the global metrics factory. 62 | /// 63 | /// - Parameters: 64 | /// - configuration: The configuration for the monitor. 65 | /// - metricsFactory: The metrics factory to use for creating metrics. 66 | /// - dataProvider: The provider to use for collecting system metrics data. 67 | /// - logger: A custom logger. 68 | package init( 69 | configuration: SystemMetricsMonitor.Configuration, 70 | metricsFactory: (any MetricsFactory)?, 71 | dataProvider: any SystemMetricsProvider, 72 | logger: Logger 73 | ) { 74 | self.configuration = configuration 75 | self.metricsFactory = metricsFactory 76 | self.dataProvider = dataProvider 77 | self.logger = logger 78 | 79 | // Initialize gauges once to avoid repeated creation in updateMetrics() 80 | let effectiveMetricsFactory = metricsFactory ?? MetricsSystem.factory 81 | self.virtualMemoryBytesGauge = Gauge( 82 | label: configuration.labels.label(for: \.virtualMemoryBytes), 83 | dimensions: configuration.dimensions, 84 | factory: effectiveMetricsFactory 85 | ) 86 | self.residentMemoryBytesGauge = Gauge( 87 | label: configuration.labels.label(for: \.residentMemoryBytes), 88 | dimensions: configuration.dimensions, 89 | factory: effectiveMetricsFactory 90 | ) 91 | self.startTimeSecondsGauge = Gauge( 92 | label: configuration.labels.label(for: \.startTimeSeconds), 93 | dimensions: configuration.dimensions, 94 | factory: effectiveMetricsFactory 95 | ) 96 | self.cpuSecondsTotalGauge = Gauge( 97 | label: configuration.labels.label(for: \.cpuSecondsTotal), 98 | dimensions: configuration.dimensions, 99 | factory: effectiveMetricsFactory 100 | ) 101 | self.maxFileDescriptorsGauge = Gauge( 102 | label: configuration.labels.label(for: \.maxFileDescriptors), 103 | dimensions: configuration.dimensions, 104 | factory: effectiveMetricsFactory 105 | ) 106 | self.openFileDescriptorsGauge = Gauge( 107 | label: configuration.labels.label(for: \.openFileDescriptors), 108 | dimensions: configuration.dimensions, 109 | factory: effectiveMetricsFactory 110 | ) 111 | } 112 | 113 | /// Create a new ``SystemMetricsMonitor`` with a custom data provider. 114 | /// 115 | /// - Parameters: 116 | /// - configuration: The configuration for the monitor. 117 | /// - dataProvider: The provider to use for collecting system metrics data. 118 | /// - logger: A custom logger. 119 | package init( 120 | configuration: SystemMetricsMonitor.Configuration, 121 | dataProvider: any SystemMetricsProvider, 122 | logger: Logger 123 | ) { 124 | self.init( 125 | configuration: configuration, 126 | metricsFactory: nil, 127 | dataProvider: dataProvider, 128 | logger: logger 129 | ) 130 | } 131 | 132 | /// Create a new ``SystemMetricsMonitor`` with a custom metrics factory. 133 | /// 134 | /// - Parameters: 135 | /// - configuration: The configuration for the monitor. 136 | /// - metricsFactory: The metrics factory to use for creating metrics. 137 | /// - logger: A custom logger. 138 | public init( 139 | configuration: SystemMetricsMonitor.Configuration = .default, 140 | metricsFactory: any MetricsFactory, 141 | logger: Logger 142 | ) { 143 | self.init( 144 | configuration: configuration, 145 | metricsFactory: metricsFactory, 146 | dataProvider: SystemMetricsMonitorDataProvider(configuration: configuration), 147 | logger: logger 148 | ) 149 | } 150 | 151 | /// Create a new ``SystemMetricsMonitor`` using the global metrics factory. 152 | /// 153 | /// - Parameters: 154 | /// - configuration: The configuration for the monitor. 155 | /// - logger: A custom logger. 156 | public init( 157 | configuration: SystemMetricsMonitor.Configuration = .default, 158 | logger: Logger 159 | ) { 160 | self.init( 161 | configuration: configuration, 162 | metricsFactory: nil, 163 | dataProvider: SystemMetricsMonitorDataProvider(configuration: configuration), 164 | logger: logger 165 | ) 166 | } 167 | 168 | /// Collect and report system metrics once. 169 | /// 170 | /// This method collects current system metrics and reports them as gauges 171 | /// using the configured labels and dimensions. If metric collection fails 172 | /// or is unsupported on the current platform, this method returns without 173 | /// reporting any metrics. 174 | package func updateMetrics() async { 175 | guard let metrics = await self.dataProvider.data() else { 176 | self.logger.debug("Failed to fetch the latest system metrics") 177 | return 178 | } 179 | self.logger.trace( 180 | "Fetched the latest system metrics", 181 | metadata: [ 182 | self.configuration.labels.virtualMemoryBytes.description: Logger.MetadataValue( 183 | "\(metrics.virtualMemoryBytes)" 184 | ), 185 | self.configuration.labels.residentMemoryBytes.description: Logger.MetadataValue( 186 | "\(metrics.residentMemoryBytes)" 187 | ), 188 | self.configuration.labels.startTimeSeconds.description: Logger.MetadataValue( 189 | "\(metrics.startTimeSeconds)" 190 | ), 191 | self.configuration.labels.cpuSecondsTotal.description: Logger.MetadataValue("\(metrics.cpuSeconds)"), 192 | self.configuration.labels.maxFileDescriptors.description: Logger.MetadataValue( 193 | "\(metrics.maxFileDescriptors)" 194 | ), 195 | self.configuration.labels.openFileDescriptors.description: Logger.MetadataValue( 196 | "\(metrics.openFileDescriptors)" 197 | ), 198 | ] 199 | ) 200 | self.virtualMemoryBytesGauge.record(metrics.virtualMemoryBytes) 201 | self.residentMemoryBytesGauge.record(metrics.residentMemoryBytes) 202 | self.startTimeSecondsGauge.record(metrics.startTimeSeconds) 203 | self.cpuSecondsTotalGauge.record(metrics.cpuSeconds) 204 | self.maxFileDescriptorsGauge.record(metrics.maxFileDescriptors) 205 | self.openFileDescriptorsGauge.record(metrics.openFileDescriptors) 206 | } 207 | 208 | /// Start the monitoring loop, collecting and reporting metrics at the configured interval. 209 | /// 210 | /// This method runs indefinitely, periodically collecting and reporting system metrics 211 | /// according to the poll interval specified in the configuration. It will only return 212 | /// if the async task is cancelled. 213 | public func run() async throws { 214 | for await _ in AsyncTimerSequence(interval: self.configuration.interval, clock: .continuous) 215 | .cancelOnGracefulShutdown() 216 | { 217 | await self.updateMetrics() 218 | } 219 | } 220 | } 221 | 222 | /// A protocol for providing system metrics data. 223 | /// 224 | /// Types conforming to this protocol can provide system metrics data 225 | /// to a ``SystemMetricsMonitor``. This allows for flexible data collection 226 | /// strategies, including custom implementations for testing. 227 | package protocol SystemMetricsProvider: Sendable { 228 | /// Retrieve current system metrics data. 229 | /// 230 | /// - Returns: Current system metrics, or `nil` if collection failed 231 | /// or is unsupported on the current platform. 232 | func data() async -> SystemMetricsMonitor.Data? 233 | } 234 | 235 | /// Default implementation of ``SystemMetricsProvider`` for collecting system metrics data. 236 | /// 237 | /// This provider collects process-level metrics from the operating system. 238 | /// It is used as the default data provider when no custom provider is specified. 239 | package struct SystemMetricsMonitorDataProvider: Sendable { 240 | let configuration: SystemMetricsMonitor.Configuration 241 | 242 | package init(configuration: SystemMetricsMonitor.Configuration) { 243 | self.configuration = configuration 244 | } 245 | } 246 | 247 | extension SystemMetricsMonitor { 248 | /// System Metrics data. 249 | /// 250 | /// The current list of metrics exposed is a superset of the [Prometheus Client Library Guidelines](https://prometheus.io/docs/instrumenting/writing_clientlibs/#standard-and-runtime-collectors). 251 | package struct Data: Sendable { 252 | /// Virtual memory size in bytes. 253 | package var virtualMemoryBytes: Int 254 | /// Resident memory size in bytes. 255 | package var residentMemoryBytes: Int 256 | /// Start time of the process since Unix epoch in seconds. 257 | package var startTimeSeconds: Int 258 | /// Total user and system CPU time spent in seconds. 259 | package var cpuSeconds: Double 260 | /// Maximum number of open file descriptors. 261 | package var maxFileDescriptors: Int 262 | /// Number of open file descriptors. 263 | package var openFileDescriptors: Int 264 | 265 | /// Create a new `Data` instance. 266 | /// 267 | /// - parameters: 268 | /// - virtualMemoryBytes: Virtual memory size in bytes 269 | /// - residentMemoryBytes: Resident memory size in bytes. 270 | /// - startTimeSeconds: Process start time since Unix epoch in seconds. 271 | /// - cpuSeconds: Total user and system CPU time spent in seconds. 272 | /// - maxFileDescriptors: Maximum number of open file descriptors. 273 | /// - openFileDescriptors: Number of open file descriptors. 274 | package init( 275 | virtualMemoryBytes: Int, 276 | residentMemoryBytes: Int, 277 | startTimeSeconds: Int, 278 | cpuSeconds: Double, 279 | maxFileDescriptors: Int, 280 | openFileDescriptors: Int 281 | ) { 282 | self.virtualMemoryBytes = virtualMemoryBytes 283 | self.residentMemoryBytes = residentMemoryBytes 284 | self.startTimeSeconds = startTimeSeconds 285 | self.cpuSeconds = cpuSeconds 286 | self.maxFileDescriptors = maxFileDescriptors 287 | self.openFileDescriptors = openFileDescriptors 288 | } 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /Examples/ServiceIntegration/grafana/provisioning/dashboards/grafana-dashboard-service-process-metrics.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "grafana" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "type": "dashboard" 15 | } 16 | ] 17 | }, 18 | "description": "Process System Metrics collected for the service", 19 | "editable": true, 20 | "fiscalYearStartMonth": 0, 21 | "graphTooltip": 0, 22 | "id": 4, 23 | "links": [], 24 | "panels": [ 25 | { 26 | "datasource": { 27 | "type": "prometheus", 28 | "uid": "prometheus" 29 | }, 30 | "description": "", 31 | "fieldConfig": { 32 | "defaults": { 33 | "color": { 34 | "mode": "thresholds", 35 | "seriesBy": "last" 36 | }, 37 | "custom": { 38 | "axisBorderShow": false, 39 | "axisCenteredZero": false, 40 | "axisColorMode": "text", 41 | "axisLabel": "", 42 | "axisPlacement": "auto", 43 | "barAlignment": -1, 44 | "barWidthFactor": 0.6, 45 | "drawStyle": "line", 46 | "fillOpacity": 0, 47 | "gradientMode": "none", 48 | "hideFrom": { 49 | "legend": false, 50 | "tooltip": false, 51 | "viz": false 52 | }, 53 | "insertNulls": false, 54 | "lineInterpolation": "linear", 55 | "lineStyle": { 56 | "fill": "solid" 57 | }, 58 | "lineWidth": 1, 59 | "pointSize": 5, 60 | "scaleDistribution": { 61 | "type": "linear" 62 | }, 63 | "showPoints": "auto", 64 | "showValues": false, 65 | "spanNulls": false, 66 | "stacking": { 67 | "group": "A", 68 | "mode": "none" 69 | }, 70 | "thresholdsStyle": { 71 | "mode": "off" 72 | } 73 | }, 74 | "decimals": 0, 75 | "fieldMinMax": false, 76 | "mappings": [], 77 | "thresholds": { 78 | "mode": "absolute", 79 | "steps": [ 80 | { 81 | "color": "green", 82 | "value": 0 83 | } 84 | ] 85 | }, 86 | "unit": "s" 87 | }, 88 | "overrides": [] 89 | }, 90 | "gridPos": { 91 | "h": 8, 92 | "w": 12, 93 | "x": 0, 94 | "y": 0 95 | }, 96 | "id": 4, 97 | "options": { 98 | "legend": { 99 | "calcs": [], 100 | "displayMode": "list", 101 | "placement": "bottom", 102 | "showLegend": false 103 | }, 104 | "tooltip": { 105 | "hideZeros": false, 106 | "mode": "single", 107 | "sort": "none" 108 | } 109 | }, 110 | "pluginVersion": "12.3.0", 111 | "targets": [ 112 | { 113 | "datasource": { 114 | "type": "prometheus", 115 | "uid": "prometheus" 116 | }, 117 | "editorMode": "code", 118 | "exemplar": false, 119 | "expr": "time() - process_start_time_seconds", 120 | "hide": false, 121 | "instant": false, 122 | "interval": "", 123 | "legendFormat": "Service Uptime", 124 | "range": true, 125 | "refId": "A" 126 | } 127 | ], 128 | "title": "Service Uptime", 129 | "type": "timeseries" 130 | }, 131 | { 132 | "description": "", 133 | "fieldConfig": { 134 | "defaults": { 135 | "color": { 136 | "mode": "palette-classic" 137 | }, 138 | "custom": { 139 | "axisBorderShow": false, 140 | "axisCenteredZero": false, 141 | "axisColorMode": "text", 142 | "axisLabel": "", 143 | "axisPlacement": "auto", 144 | "barAlignment": 0, 145 | "barWidthFactor": 0.6, 146 | "drawStyle": "line", 147 | "fillOpacity": 0, 148 | "gradientMode": "none", 149 | "hideFrom": { 150 | "legend": false, 151 | "tooltip": false, 152 | "viz": false 153 | }, 154 | "insertNulls": false, 155 | "lineInterpolation": "linear", 156 | "lineWidth": 1, 157 | "pointSize": 5, 158 | "scaleDistribution": { 159 | "type": "linear" 160 | }, 161 | "showPoints": "auto", 162 | "showValues": false, 163 | "spanNulls": false, 164 | "stacking": { 165 | "group": "A", 166 | "mode": "none" 167 | }, 168 | "thresholdsStyle": { 169 | "mode": "off" 170 | } 171 | }, 172 | "mappings": [], 173 | "thresholds": { 174 | "mode": "absolute", 175 | "steps": [ 176 | { 177 | "color": "green", 178 | "value": 0 179 | }, 180 | { 181 | "color": "red", 182 | "value": 80 183 | } 184 | ] 185 | }, 186 | "unit": "percentunit" 187 | }, 188 | "overrides": [] 189 | }, 190 | "gridPos": { 191 | "h": 8, 192 | "w": 12, 193 | "x": 0, 194 | "y": 8 195 | }, 196 | "id": 1, 197 | "options": { 198 | "legend": { 199 | "calcs": [], 200 | "displayMode": "list", 201 | "placement": "bottom", 202 | "showLegend": true 203 | }, 204 | "tooltip": { 205 | "hideZeros": false, 206 | "mode": "single", 207 | "sort": "none" 208 | } 209 | }, 210 | "pluginVersion": "12.3.0", 211 | "targets": [ 212 | { 213 | "datasource": { 214 | "type": "prometheus", 215 | "uid": "prometheus" 216 | }, 217 | "editorMode": "builder", 218 | "expr": "rate(process_cpu_seconds_total[$__rate_interval])", 219 | "hide": false, 220 | "instant": false, 221 | "legendFormat": "CPU Usage", 222 | "range": true, 223 | "refId": "A" 224 | } 225 | ], 226 | "title": "CPU Usage", 227 | "type": "timeseries" 228 | }, 229 | { 230 | "description": "", 231 | "fieldConfig": { 232 | "defaults": { 233 | "color": { 234 | "mode": "palette-classic" 235 | }, 236 | "custom": { 237 | "axisBorderShow": false, 238 | "axisCenteredZero": false, 239 | "axisColorMode": "text", 240 | "axisLabel": "", 241 | "axisPlacement": "auto", 242 | "barAlignment": 0, 243 | "barWidthFactor": 0.6, 244 | "drawStyle": "line", 245 | "fillOpacity": 0, 246 | "gradientMode": "none", 247 | "hideFrom": { 248 | "legend": false, 249 | "tooltip": false, 250 | "viz": false 251 | }, 252 | "insertNulls": false, 253 | "lineInterpolation": "linear", 254 | "lineWidth": 1, 255 | "pointSize": 5, 256 | "scaleDistribution": { 257 | "type": "linear" 258 | }, 259 | "showPoints": "auto", 260 | "showValues": false, 261 | "spanNulls": false, 262 | "stacking": { 263 | "group": "A", 264 | "mode": "none" 265 | }, 266 | "thresholdsStyle": { 267 | "mode": "off" 268 | } 269 | }, 270 | "mappings": [], 271 | "thresholds": { 272 | "mode": "absolute", 273 | "steps": [ 274 | { 275 | "color": "green", 276 | "value": 0 277 | }, 278 | { 279 | "color": "red", 280 | "value": 80 281 | } 282 | ] 283 | }, 284 | "unit": "decbytes" 285 | }, 286 | "overrides": [] 287 | }, 288 | "gridPos": { 289 | "h": 8, 290 | "w": 12, 291 | "x": 0, 292 | "y": 16 293 | }, 294 | "id": 2, 295 | "options": { 296 | "legend": { 297 | "calcs": [], 298 | "displayMode": "list", 299 | "placement": "bottom", 300 | "showLegend": true 301 | }, 302 | "tooltip": { 303 | "hideZeros": false, 304 | "mode": "single", 305 | "sort": "none" 306 | } 307 | }, 308 | "pluginVersion": "12.3.0", 309 | "targets": [ 310 | { 311 | "datasource": { 312 | "type": "prometheus", 313 | "uid": "prometheus" 314 | }, 315 | "editorMode": "builder", 316 | "expr": "process_resident_memory_bytes{service_name=\"ServiceIntegrationExample\"}", 317 | "hide": false, 318 | "instant": false, 319 | "legendFormat": "Residential Memory", 320 | "range": true, 321 | "refId": "A" 322 | }, 323 | { 324 | "datasource": { 325 | "type": "prometheus", 326 | "uid": "prometheus" 327 | }, 328 | "editorMode": "builder", 329 | "expr": "process_virtual_memory_bytes{service_name=\"ServiceIntegrationExample\"}", 330 | "hide": false, 331 | "instant": false, 332 | "legendFormat": "Virtual Memory", 333 | "range": true, 334 | "refId": "B" 335 | } 336 | ], 337 | "title": "Memory Consumption", 338 | "type": "timeseries" 339 | }, 340 | { 341 | "description": "", 342 | "fieldConfig": { 343 | "defaults": { 344 | "color": { 345 | "mode": "palette-classic" 346 | }, 347 | "custom": { 348 | "axisBorderShow": false, 349 | "axisCenteredZero": false, 350 | "axisColorMode": "text", 351 | "axisLabel": "", 352 | "axisPlacement": "auto", 353 | "barAlignment": 0, 354 | "barWidthFactor": 0.6, 355 | "drawStyle": "line", 356 | "fillOpacity": 0, 357 | "gradientMode": "none", 358 | "hideFrom": { 359 | "legend": false, 360 | "tooltip": false, 361 | "viz": false 362 | }, 363 | "insertNulls": false, 364 | "lineInterpolation": "linear", 365 | "lineWidth": 1, 366 | "pointSize": 5, 367 | "scaleDistribution": { 368 | "type": "linear" 369 | }, 370 | "showPoints": "auto", 371 | "showValues": false, 372 | "spanNulls": false, 373 | "stacking": { 374 | "group": "A", 375 | "mode": "none" 376 | }, 377 | "thresholdsStyle": { 378 | "mode": "off" 379 | } 380 | }, 381 | "decimals": 0, 382 | "mappings": [], 383 | "thresholds": { 384 | "mode": "absolute", 385 | "steps": [ 386 | { 387 | "color": "green", 388 | "value": 0 389 | }, 390 | { 391 | "color": "red", 392 | "value": 80 393 | } 394 | ] 395 | }, 396 | "unit": "none" 397 | }, 398 | "overrides": [] 399 | }, 400 | "gridPos": { 401 | "h": 8, 402 | "w": 12, 403 | "x": 0, 404 | "y": 24 405 | }, 406 | "id": 3, 407 | "options": { 408 | "legend": { 409 | "calcs": [], 410 | "displayMode": "list", 411 | "placement": "bottom", 412 | "showLegend": true 413 | }, 414 | "tooltip": { 415 | "hideZeros": false, 416 | "mode": "single", 417 | "sort": "none" 418 | } 419 | }, 420 | "pluginVersion": "12.3.0", 421 | "targets": [ 422 | { 423 | "datasource": { 424 | "type": "prometheus", 425 | "uid": "prometheus" 426 | }, 427 | "editorMode": "builder", 428 | "expr": "process_open_fds", 429 | "hide": false, 430 | "instant": false, 431 | "legendFormat": "Open File Descriptors", 432 | "range": true, 433 | "refId": "A" 434 | }, 435 | { 436 | "datasource": { 437 | "type": "prometheus", 438 | "uid": "prometheus" 439 | }, 440 | "editorMode": "builder", 441 | "expr": "process_max_fds", 442 | "hide": true, 443 | "instant": false, 444 | "legendFormat": "Max File Descriptors", 445 | "range": true, 446 | "refId": "B" 447 | } 448 | ], 449 | "title": "File Descriptors", 450 | "type": "timeseries" 451 | } 452 | ], 453 | "preload": false, 454 | "refresh": "5s", 455 | "schemaVersion": 42, 456 | "tags": [], 457 | "templating": { 458 | "list": [] 459 | }, 460 | "time": { 461 | "from": "now-24h", 462 | "to": "now" 463 | }, 464 | "timepicker": {}, 465 | "timezone": "browser", 466 | "title": "Process System Metrics", 467 | "uid": "gvlj4v", 468 | "version": 1 469 | } 470 | -------------------------------------------------------------------------------- /Tests/SystemMetricsTests/SystemMetricsMonitorTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift Metrics API open source project 4 | // 5 | // Copyright (c) 2018-2020 Apple Inc. and the Swift Metrics API project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of Swift Metrics API project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import CoreMetrics 16 | import Dispatch 17 | import Foundation 18 | import Logging 19 | import MetricsTestKit 20 | import SystemMetrics 21 | import Testing 22 | 23 | #if canImport(Glibc) 24 | import Glibc 25 | #elseif canImport(Musl) 26 | import Musl 27 | #endif 28 | 29 | struct MockMetricsProvider: SystemMetricsProvider { 30 | let mockData: SystemMetricsMonitor.Data? 31 | 32 | func data() async -> SystemMetricsMonitor.Data? { 33 | mockData 34 | } 35 | } 36 | 37 | @Suite("SystemMetricsMonitor Tests") 38 | struct SystemMetricsMonitorTests { 39 | @Test("Custom labels with prefix are correctly formatted") 40 | func systemMetricsLabels() throws { 41 | let labels = SystemMetricsMonitor.Configuration.Labels( 42 | prefix: "pfx+", 43 | virtualMemoryBytes: "vmb", 44 | residentMemoryBytes: "rmb", 45 | startTimeSeconds: "sts", 46 | cpuSecondsTotal: "cpt", 47 | maxFileDescriptors: "mfd", 48 | openFileDescriptors: "ofd" 49 | ) 50 | 51 | #expect(labels.label(for: \.virtualMemoryBytes) == "pfx+vmb") 52 | #expect(labels.label(for: \.residentMemoryBytes) == "pfx+rmb") 53 | #expect(labels.label(for: \.startTimeSeconds) == "pfx+sts") 54 | #expect(labels.label(for: \.cpuSecondsTotal) == "pfx+cpt") 55 | #expect(labels.label(for: \.maxFileDescriptors) == "pfx+mfd") 56 | #expect(labels.label(for: \.openFileDescriptors) == "pfx+ofd") 57 | } 58 | 59 | @Test("Configuration preserves all provided settings") 60 | func systemMetricsConfiguration() throws { 61 | let labels = SystemMetricsMonitor.Configuration.Labels( 62 | prefix: "pfx_", 63 | virtualMemoryBytes: "vmb", 64 | residentMemoryBytes: "rmb", 65 | startTimeSeconds: "sts", 66 | cpuSecondsTotal: "cpt", 67 | maxFileDescriptors: "mfd", 68 | openFileDescriptors: "ofd" 69 | ) 70 | let dimensions = [("app", "example"), ("environment", "production")] 71 | let configuration = SystemMetricsMonitor.Configuration( 72 | pollInterval: .microseconds(123_456_789), 73 | labels: labels, 74 | dimensions: dimensions 75 | ) 76 | 77 | #expect(configuration.interval == .microseconds(123_456_789)) 78 | 79 | #expect(configuration.labels.label(for: \.virtualMemoryBytes) == "pfx_vmb") 80 | #expect(configuration.labels.label(for: \.residentMemoryBytes) == "pfx_rmb") 81 | #expect(configuration.labels.label(for: \.startTimeSeconds) == "pfx_sts") 82 | #expect(configuration.labels.label(for: \.cpuSecondsTotal) == "pfx_cpt") 83 | #expect(configuration.labels.label(for: \.maxFileDescriptors) == "pfx_mfd") 84 | #expect(configuration.labels.label(for: \.openFileDescriptors) == "pfx_ofd") 85 | 86 | #expect(configuration.dimensions.contains(where: { $0 == ("app", "example") })) 87 | #expect(configuration.dimensions.contains(where: { $0 == ("environment", "production") })) 88 | 89 | #expect(!configuration.dimensions.contains(where: { $0 == ("environment", "staging") })) 90 | #expect(!configuration.dimensions.contains(where: { $0 == ("process", "example") })) 91 | } 92 | 93 | @Test("Monitor with custom provider reports metrics correctly") 94 | func monitorWithCustomProvider() async throws { 95 | let logger = Logger(label: "SystemMetricsMonitorTests") 96 | let mockData = SystemMetricsMonitor.Data( 97 | virtualMemoryBytes: 1000, 98 | residentMemoryBytes: 2000, 99 | startTimeSeconds: 3000, 100 | cpuSeconds: 4000, 101 | maxFileDescriptors: 5000, 102 | openFileDescriptors: 6000 103 | ) 104 | 105 | let provider = MockMetricsProvider(mockData: mockData) 106 | let testMetrics = TestMetrics() 107 | 108 | let labels = SystemMetricsMonitor.Configuration.Labels( 109 | prefix: "test_", 110 | virtualMemoryBytes: "vmb", 111 | residentMemoryBytes: "rmb", 112 | startTimeSeconds: "sts", 113 | cpuSecondsTotal: "cpt", 114 | maxFileDescriptors: "mfd", 115 | openFileDescriptors: "ofd" 116 | ) 117 | 118 | let configuration = SystemMetricsMonitor.Configuration( 119 | pollInterval: .seconds(1), 120 | labels: labels 121 | ) 122 | 123 | let monitor = SystemMetricsMonitor( 124 | configuration: configuration, 125 | metricsFactory: testMetrics, 126 | dataProvider: provider, 127 | logger: logger 128 | ) 129 | 130 | await monitor.updateMetrics() 131 | 132 | let vmbGauge = try testMetrics.expectGauge("test_vmb") 133 | #expect(vmbGauge.lastValue == 1000) 134 | 135 | let rmbGauge = try testMetrics.expectGauge("test_rmb") 136 | #expect(rmbGauge.lastValue == 2000) 137 | 138 | let stsGauge = try testMetrics.expectGauge("test_sts") 139 | #expect(stsGauge.lastValue == 3000) 140 | 141 | let cptGauge = try testMetrics.expectGauge("test_cpt") 142 | #expect(cptGauge.lastValue == 4000) 143 | 144 | let mfdGauge = try testMetrics.expectGauge("test_mfd") 145 | #expect(mfdGauge.lastValue == 5000) 146 | 147 | let ofdGauge = try testMetrics.expectGauge("test_ofd") 148 | #expect(ofdGauge.lastValue == 6000) 149 | } 150 | 151 | @Test("Monitor with nil provider does not report metrics") 152 | func monitorWithNilProvider() async throws { 153 | let logger = Logger(label: "SystemMetricsMonitorTests") 154 | let provider = MockMetricsProvider(mockData: nil) 155 | let testMetrics = TestMetrics() 156 | 157 | let labels = SystemMetricsMonitor.Configuration.Labels( 158 | prefix: "test_", 159 | virtualMemoryBytes: "vmb", 160 | residentMemoryBytes: "rmb", 161 | startTimeSeconds: "sts", 162 | cpuSecondsTotal: "cpt", 163 | maxFileDescriptors: "mfd", 164 | openFileDescriptors: "ofd" 165 | ) 166 | 167 | let configuration = SystemMetricsMonitor.Configuration( 168 | pollInterval: .seconds(1), 169 | labels: labels 170 | ) 171 | 172 | let monitor = SystemMetricsMonitor( 173 | configuration: configuration, 174 | metricsFactory: testMetrics, 175 | dataProvider: provider, 176 | logger: logger 177 | ) 178 | 179 | // Recorders are created along with the Monitor 180 | #expect(testMetrics.recorders.count == 6) 181 | 182 | await monitor.updateMetrics() 183 | 184 | // But no values recorded 185 | for recorder in testMetrics.recorders { 186 | #expect(recorder.values.isEmpty) 187 | } 188 | } 189 | 190 | @Test("Monitor with dimensions includes them in recorded metrics") 191 | func monitorWithDimensions() async throws { 192 | let logger = Logger(label: "SystemMetricsMonitorTests") 193 | let mockData = SystemMetricsMonitor.Data( 194 | virtualMemoryBytes: 1000, 195 | residentMemoryBytes: 2000, 196 | startTimeSeconds: 3000, 197 | cpuSeconds: 4000, 198 | maxFileDescriptors: 5000, 199 | openFileDescriptors: 6000 200 | ) 201 | 202 | let provider = MockMetricsProvider(mockData: mockData) 203 | let testMetrics = TestMetrics() 204 | 205 | let labels = SystemMetricsMonitor.Configuration.Labels( 206 | prefix: "test_", 207 | virtualMemoryBytes: "vmb", 208 | residentMemoryBytes: "rmb", 209 | startTimeSeconds: "sts", 210 | cpuSecondsTotal: "cpt", 211 | maxFileDescriptors: "mfd", 212 | openFileDescriptors: "ofd" 213 | ) 214 | 215 | let dimensions = [("service", "myapp"), ("environment", "production")] 216 | let configuration = SystemMetricsMonitor.Configuration( 217 | pollInterval: .seconds(1), 218 | labels: labels, 219 | dimensions: dimensions 220 | ) 221 | 222 | let monitor = SystemMetricsMonitor( 223 | configuration: configuration, 224 | metricsFactory: testMetrics, 225 | dataProvider: provider, 226 | logger: logger 227 | ) 228 | 229 | await monitor.updateMetrics() 230 | 231 | let vmbGauge = try testMetrics.expectGauge("test_vmb", dimensions) 232 | #expect(vmbGauge.lastValue == 1000) 233 | } 234 | 235 | @Test("Monitor run() method collects metrics periodically") 236 | func monitorRunPeriodically() async throws { 237 | let logger = Logger(label: "SystemMetricsMonitorTests") 238 | 239 | actor CallCountingProvider: SystemMetricsProvider { 240 | var callCount = 0 241 | let mockData: SystemMetricsMonitor.Data 242 | 243 | init(mockData: SystemMetricsMonitor.Data) { 244 | self.mockData = mockData 245 | } 246 | 247 | func data() async -> SystemMetricsMonitor.Data? { 248 | callCount += 1 249 | return mockData 250 | } 251 | 252 | func getCallCount() -> Int { 253 | callCount 254 | } 255 | } 256 | 257 | let mockData = SystemMetricsMonitor.Data( 258 | virtualMemoryBytes: 1000, 259 | residentMemoryBytes: 2000, 260 | startTimeSeconds: 3000, 261 | cpuSeconds: 4000, 262 | maxFileDescriptors: 5000, 263 | openFileDescriptors: 6000 264 | ) 265 | 266 | let provider = CallCountingProvider(mockData: mockData) 267 | let testMetrics = TestMetrics() 268 | 269 | let labels = SystemMetricsMonitor.Configuration.Labels( 270 | prefix: "test_", 271 | virtualMemoryBytes: "vmb", 272 | residentMemoryBytes: "rmb", 273 | startTimeSeconds: "sts", 274 | cpuSecondsTotal: "cpt", 275 | maxFileDescriptors: "mfd", 276 | openFileDescriptors: "ofd" 277 | ) 278 | 279 | let configuration = SystemMetricsMonitor.Configuration( 280 | pollInterval: .milliseconds(100), 281 | labels: labels 282 | ) 283 | 284 | let monitor = SystemMetricsMonitor( 285 | configuration: configuration, 286 | metricsFactory: testMetrics, 287 | dataProvider: provider, 288 | logger: logger 289 | ) 290 | 291 | // Wait for the monitor to run a few times 292 | #expect( 293 | try await wait( 294 | noLongerThan: .seconds(2.0), 295 | for: { 296 | await provider.getCallCount() >= 3 297 | }, 298 | while: { 299 | try await monitor.run() 300 | } 301 | ) 302 | ) 303 | 304 | let vmbGauge = try testMetrics.expectGauge("test_vmb") 305 | #expect(vmbGauge.lastValue == 1000) 306 | } 307 | 308 | @Test("Monitor with default provider uses platform implementation") 309 | func monitorWithDefaultProvider() async throws { 310 | let logger = Logger(label: "test") 311 | let testMetrics = TestMetrics() 312 | 313 | let labels = SystemMetricsMonitor.Configuration.Labels( 314 | prefix: "test_", 315 | virtualMemoryBytes: "vmb", 316 | residentMemoryBytes: "rmb", 317 | startTimeSeconds: "sts", 318 | cpuSecondsTotal: "cpt", 319 | maxFileDescriptors: "mfd", 320 | openFileDescriptors: "ofd" 321 | ) 322 | 323 | let configuration = SystemMetricsMonitor.Configuration( 324 | pollInterval: .seconds(1), 325 | labels: labels 326 | ) 327 | 328 | // No custom provider - uses SystemMetricsMonitorDataProvider internally 329 | let monitor = SystemMetricsMonitor( 330 | configuration: configuration, 331 | metricsFactory: testMetrics, 332 | logger: logger 333 | ) 334 | 335 | await monitor.updateMetrics() 336 | let vmbGauge = try testMetrics.expectGauge("test_vmb") 337 | 338 | #if os(macOS) || os(Linux) 339 | let lastValue = try #require(vmbGauge.lastValue) 340 | #expect(lastValue > 0) 341 | #endif 342 | } 343 | } 344 | 345 | @Suite("SystemMetrics with MetricsSystem Initialization Tests", .serialized) 346 | struct SystemMetricsInitializationTests { 347 | static let sharedSetup: TestMetrics = { 348 | let testMetrics = TestMetrics() 349 | MetricsSystem.bootstrap(testMetrics) 350 | return testMetrics 351 | }() 352 | 353 | let testMetrics: TestMetrics = Self.sharedSetup 354 | 355 | @Test("Monitor uses global MetricsSystem when no factory provided") 356 | func monitorUsesGlobalMetricsSystem() async throws { 357 | let logger = Logger(label: "SystemMetricsMonitorTests") 358 | let mockData = SystemMetricsMonitor.Data( 359 | virtualMemoryBytes: 1000, 360 | residentMemoryBytes: 2000, 361 | startTimeSeconds: 3000, 362 | cpuSeconds: 4000, 363 | maxFileDescriptors: 5000, 364 | openFileDescriptors: 6000 365 | ) 366 | 367 | let provider = MockMetricsProvider(mockData: mockData) 368 | 369 | let labels = SystemMetricsMonitor.Configuration.Labels( 370 | prefix: "global_", 371 | virtualMemoryBytes: "vmb", 372 | residentMemoryBytes: "rmb", 373 | startTimeSeconds: "sts", 374 | cpuSecondsTotal: "cpt", 375 | maxFileDescriptors: "mfd", 376 | openFileDescriptors: "ofd" 377 | ) 378 | 379 | let configuration = SystemMetricsMonitor.Configuration( 380 | pollInterval: .seconds(1), 381 | labels: labels 382 | ) 383 | 384 | // No custom factory provided - should use global MetricsSystem 385 | let monitor = SystemMetricsMonitor( 386 | configuration: configuration, 387 | dataProvider: provider, 388 | logger: logger 389 | ) 390 | 391 | await monitor.updateMetrics() 392 | 393 | let vmbGauge = try testMetrics.expectGauge("global_vmb") 394 | #expect(vmbGauge.lastValue == 1000) 395 | 396 | let rmbGauge = try testMetrics.expectGauge("global_rmb") 397 | #expect(rmbGauge.lastValue == 2000) 398 | } 399 | 400 | @Test("Monitor with default provider uses platform implementation") 401 | func monitorWithDefaultProvider() async throws { 402 | let logger = Logger(label: "test") 403 | let labels = SystemMetricsMonitor.Configuration.Labels( 404 | prefix: "default_", 405 | virtualMemoryBytes: "vmb", 406 | residentMemoryBytes: "rmb", 407 | startTimeSeconds: "sts", 408 | cpuSecondsTotal: "cpt", 409 | maxFileDescriptors: "mfd", 410 | openFileDescriptors: "ofd" 411 | ) 412 | 413 | let configuration = SystemMetricsMonitor.Configuration( 414 | pollInterval: .seconds(1), 415 | labels: labels 416 | ) 417 | 418 | // No custom provider - uses SystemMetricsMonitorDataProvider internally 419 | let monitor = SystemMetricsMonitor( 420 | configuration: configuration, 421 | logger: logger 422 | ) 423 | 424 | await monitor.updateMetrics() 425 | let vmbGauge = try testMetrics.expectGauge("default_vmb") 426 | 427 | #if os(macOS) || os(Linux) 428 | let lastValue = try #require(vmbGauge.lastValue) 429 | #expect(lastValue > 0) 430 | #endif 431 | } 432 | } 433 | --------------------------------------------------------------------------------