From 39277da8e506271f65af2f90c9faffc2dadbc821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Thu, 15 Oct 2026 11:49:25 +0200 Subject: [PATCH] pdh: fall back to localized names of v2 counter sets (#2709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Claude Signed-off-by: Jan-Otto Kröpke Co-authored-by: Claude --- AGENTS.md | 5 + internal/pdh/collector.go | 2 +- internal/pdh/localized.go | 321 ++++++++++++++++++++++++ internal/pdh/localized_internal_test.go | 116 +++++++++ 4 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 internal/pdh/localized.go create mode 100644 internal/pdh/localized_internal_test.go diff --git a/AGENTS.md b/AGENTS.md index 4f0e5176..ed1962ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,11 @@ exercised a live Windows role. instance occurrences within a sample; their suffixes are not stable identities. Do not remove every name ending in `_Total`: legitimate process and database names can use that suffix. Use the shared aggregate-instance policy. +- PDH collectors add counters by English path. On a Windows installation + without en-US resources, the 009 name table holds localized names for v2 + (manifest-based) counter sets such as `SMB Server Shares` or `HTTP Service`; + `internal/pdh/localized.go` then maps the English names through the v2 + registration API. Classic (v1) sets such as `Processor` are not affected. - Keep `Build`, `Collect` and `Close` serialized for each collector instance. Filtered `WithCollectors` views share execution state and do not own shutdown; the original `Collection` owns collectors and MI resources. A timed-out call diff --git a/internal/pdh/collector.go b/internal/pdh/collector.go index 371555c5..d070f23b 100644 --- a/internal/pdh/collector.go +++ b/internal/pdh/collector.go @@ -291,7 +291,7 @@ func newCollector[T any](logger *slog.Logger, query *Query, resultType CounterTy var counterHandle pdhCounterHandle //nolint:nestif - if ret := AddEnglishCounter(handle, counterPath, 0, &counterHandle); ret != ErrorSuccess { + if ret := addEnglishCounter(handle, object, instance, counterName, &counterHandle); ret != ErrorSuccess { if ret == CstatusNoCounter { if f.minOSBuild != "" { if minOSBuild, err := strconv.Atoi(f.minOSBuild); err == nil { diff --git a/internal/pdh/localized.go b/internal/pdh/localized.go new file mode 100644 index 00000000..ffd00697 --- /dev/null +++ b/internal/pdh/localized.go @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package pdh + +import ( + "encoding/binary" + "slices" + "strings" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// PdhAddEnglishCounterW resolves English names through the 009 name table. +// For v2 (manifest-based) counter sets, Windows fills that table from the +// provider's MUI resources. Without en-US resources, e.g. on a German-only +// installation, it holds the localized names, so the English path of a v2 +// counter set fails with PDH_CSTATUS_NO_OBJECT. The functions below map the +// English names to the localized ones through the v2 registration API, which +// reads the names of each language from the provider itself. + +const ( + perfRegCounterSetNameString = 3 // PERF_REG_COUNTERSET_NAME_STRING + perfRegCounterNameStrings = 5 // PERF_REG_COUNTER_NAME_STRINGS + + langIDEnglish = 0x0409 +) + +//nolint:gochecknoglobals +var ( + libAdvapi32 = windows.NewLazySystemDLL("advapi32.dll") + libKernel32 = windows.NewLazySystemDLL("kernel32.dll") + + perfEnumerateCounterSet = libAdvapi32.NewProc("PerfEnumerateCounterSet") + perfQueryCounterSetRegistrationInfo = libAdvapi32.NewProc("PerfQueryCounterSetRegistrationInfo") + getUserDefaultUILanguage = libKernel32.NewProc("GetUserDefaultUILanguage") + getSystemDefaultUILanguage = libKernel32.NewProc("GetSystemDefaultUILanguage") + + // englishCounterSets maps the lower-case English name of every v2 counter + // set to its GUID. It is built on first use, because only hosts whose 009 + // table lacks a v2 counter set need it. + englishCounterSets = sync.OnceValue(loadEnglishCounterSets) + + localizedNamesMu sync.Mutex + // localizedNames caches the name mappings by lower-case English object name. + // A nil entry means the object has no localized names to fall back to. + localizedNames = map[string][]localizedCounterSet{} +) + +// localizedCounterSet holds the names of a v2 counter set in one UI language. +type localizedCounterSet struct { + object string + // counters maps lower-case English counter names to localized names. + counters map[string]string +} + +// addEnglishCounter adds the counter by its English path. If the object or +// counter is not found, it tries the localized paths of a v2 counter set. +func addEnglishCounter(handle pdhQueryHandle, object, instance, counterName string, counterHandle *pdhCounterHandle) uint32 { + ret := AddEnglishCounter(handle, formatCounterPath(object, instance, counterName), 0, counterHandle) + if ret != CstatusNoObject && ret != CstatusNoCounter { + return ret + } + + for _, path := range localizedCounterPaths(object, instance, counterName) { + if AddCounter(handle, path, 0, counterHandle) == ErrorSuccess { + return ErrorSuccess + } + } + + return ret +} + +// localizedCounterPaths returns the counter path in each UI language of the +// process and the system whose names differ from the English ones. It +// returns nil if object is not a v2 counter set or has no such translation. +func localizedCounterPaths(object, instance, counterName string) []string { + sets := localizedCounterSets(object) + + paths := make([]string, 0, len(sets)) + + for _, set := range sets { + counter, ok := set.counters[strings.ToLower(counterName)] + if !ok { + continue + } + + path := formatCounterPath(set.object, instance, counter) + if !slices.Contains(paths, path) { + paths = append(paths, path) + } + } + + return paths +} + +func localizedCounterSets(object string) []localizedCounterSet { + key := strings.ToLower(object) + + localizedNamesMu.Lock() + defer localizedNamesMu.Unlock() + + if sets, ok := localizedNames[key]; ok { + return sets + } + + var sets []localizedCounterSet + + if guid, ok := englishCounterSets()[key]; ok { + english := counterNames(&guid, langIDEnglish) + + for _, langID := range uiLanguages() { + if isEnglish(langID) { + continue + } + + name, ok := counterSetName(&guid, langID) + if !ok { + continue + } + + localized := counterNames(&guid, langID) + counters := make(map[string]string, len(english)) + + for id, englishName := range english { + if localizedName, ok := localized[id]; ok { + counters[strings.ToLower(englishName)] = localizedName + } + } + + sets = append(sets, localizedCounterSet{object: name, counters: counters}) + } + } + + localizedNames[key] = sets + + return sets +} + +// isEnglish reports whether langID is an English language, e.g. en-US or en-GB, +// whose names match the English ones. +func isEnglish(langID uint32) bool { + return langID&0x3FF == langIDEnglish&0x3FF +} + +// uiLanguages returns the UI languages whose names PDH may expect: the one of +// the process' user and the one of the system. +func uiLanguages() []uint32 { + langIDs := make([]uint32, 0, 2) + + for _, proc := range []*windows.LazyProc{getUserDefaultUILanguage, getSystemDefaultUILanguage} { + if proc.Find() != nil { + continue + } + + ret, _, _ := proc.Call() + if langID := uint32(uint16(ret)); langID != 0 && !slices.Contains(langIDs, langID) { + langIDs = append(langIDs, langID) + } + } + + return langIDs +} + +func loadEnglishCounterSets() map[string]windows.GUID { + sets := map[string]windows.GUID{} + + if perfEnumerateCounterSet.Find() != nil || perfQueryCounterSetRegistrationInfo.Find() != nil { + return sets + } + + var guids []windows.GUID + + for size := uint32(256); ; { + guids = make([]windows.GUID, size) + + var actual uint32 + + ret, _, _ := perfEnumerateCounterSet.Call( + 0, + uintptr(unsafe.Pointer(&guids[0])), + uintptr(size), + uintptr(unsafe.Pointer(&actual)), + ) + + if ret == uintptr(windows.ERROR_NOT_ENOUGH_MEMORY) && actual > size { + size = actual + + continue + } + + if ret != uintptr(windows.ERROR_SUCCESS) { + return sets + } + + guids = guids[:min(actual, size)] + + break + } + + for _, guid := range guids { + if name, ok := counterSetName(&guid, langIDEnglish); ok { + sets[strings.ToLower(name)] = guid + } + } + + return sets +} + +// queryRegistrationInfo calls PerfQueryCounterSetRegistrationInfo and grows the +// buffer until the result fits. +func queryRegistrationInfo(guid *windows.GUID, requestCode, langID uint32) ([]byte, bool) { + buf := make([]byte, 512) + + for { + var actual uint32 + + ret, _, _ := perfQueryCounterSetRegistrationInfo.Call( + 0, + uintptr(unsafe.Pointer(guid)), + uintptr(requestCode), + uintptr(langID), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + uintptr(unsafe.Pointer(&actual)), + ) + + switch { + case ret == uintptr(windows.ERROR_NOT_ENOUGH_MEMORY) && int(actual) > len(buf): + buf = make([]byte, actual) + case ret == uintptr(windows.ERROR_SUCCESS) && int(actual) <= len(buf): + return buf[:actual], true + default: + return nil, false + } + } +} + +func counterSetName(guid *windows.GUID, langID uint32) (string, bool) { + buf, ok := queryRegistrationInfo(guid, perfRegCounterSetNameString, langID) + if !ok { + return "", false + } + + name := utf16String(buf) + + return name, name != "" +} + +// counterNames returns the counter names of a counter set by counter ID. +// The result is a PERF_STRING_BUFFER_HEADER block: the header, one +// PERF_STRING_COUNTER_HEADER per counter, then the strings they point to. +func counterNames(guid *windows.GUID, langID uint32) map[uint32]string { + buf, ok := queryRegistrationInfo(guid, perfRegCounterNameStrings, langID) + if !ok { + return nil + } + + return parseCounterNames(buf) +} + +func parseCounterNames(buf []byte) map[uint32]string { + if len(buf) < 8 { + return nil + } + + count := binary.LittleEndian.Uint32(buf[4:8]) + names := make(map[uint32]string, count) + + for i := range count { + header := 8 + 8*int(i) + if header+8 > len(buf) { + break + } + + id := binary.LittleEndian.Uint32(buf[header:]) + offset := binary.LittleEndian.Uint32(buf[header+4:]) + + if offset == 0xFFFFFFFF || int(offset) >= len(buf) { + continue + } + + if name := utf16String(buf[offset:]); name != "" { + names[id] = name + } + } + + return names +} + +// utf16String decodes a NUL-terminated UTF-16LE string. +func utf16String(buf []byte) string { + chars := make([]uint16, 0, len(buf)/2) + + for i := 0; i+1 < len(buf); i += 2 { + c := binary.LittleEndian.Uint16(buf[i:]) + if c == 0 { + break + } + + chars = append(chars, c) + } + + return windows.UTF16ToString(chars) +} diff --git a/internal/pdh/localized_internal_test.go b/internal/pdh/localized_internal_test.go new file mode 100644 index 00000000..2acca87a --- /dev/null +++ b/internal/pdh/localized_internal_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package pdh + +import ( + "encoding/binary" + "testing" + "unicode/utf16" + + "github.com/stretchr/testify/require" +) + +func TestUTF16String(t *testing.T) { + buf := utf16Bytes("SMB-Serverfreigaben") + buf = append(buf, 'x', 0) + + require.Equal(t, "SMB-Serverfreigaben", utf16String(buf)) + require.Empty(t, utf16String(nil)) +} + +// TestEnglishCounterSets checks the English names of a v2 counter set that +// every supported Windows version registers. Classic sets like Processor +// Information are not v2 counter sets. +func TestEnglishCounterSets(t *testing.T) { + guid, ok := englishCounterSets()["smb client shares"] + require.True(t, ok, "SMB Client Shares counter set not found") + + names := counterNames(&guid, langIDEnglish) + require.Contains(t, mapValues(names), "Avg. sec/Read") +} + +// TestLocalizedCounterPaths checks that an English path maps to the counter +// set's names in the UI language. On an English system, there is nothing to map. +func TestLocalizedCounterPaths(t *testing.T) { + paths := localizedCounterPaths("SMB Client Shares", "*", "Avg. sec/Read") + + for _, langID := range uiLanguages() { + if isEnglish(langID) { + continue + } + + require.NotEmpty(t, paths, "no localized path for UI language %#x", langID) + } + + for _, path := range paths { + var query pdhQueryHandle + + require.Equal(t, uint32(ErrorSuccess), OpenQuery(0, 0, &query)) + + var counter pdhCounterHandle + + ret := AddCounter(query, path, 0, &counter) + CloseQuery(query) + + require.Equal(t, uint32(ErrorSuccess), ret, "add localized counter %s", path) + } +} + +func mapValues(m map[uint32]string) []string { + values := make([]string, 0, len(m)) + for _, v := range m { + values = append(values, v) + } + + return values +} + +func utf16Bytes(s string) []byte { + chars := utf16.Encode([]rune(s)) + buf := make([]byte, 0, 2*len(chars)+2) + + for _, c := range chars { + buf = binary.LittleEndian.AppendUint16(buf, c) + } + + return binary.LittleEndian.AppendUint16(buf, 0) +} + +func TestCounterNamesBuffer(t *testing.T) { + first := utf16Bytes("Anzahl derzeit geöffneter Dateien") + second := utf16Bytes("Anzahl von Strukturverbindungen") + + // PERF_STRING_BUFFER_HEADER, three PERF_STRING_COUNTER_HEADERs, strings. + const headers = 8 + 3*8 + + buf := binary.LittleEndian.AppendUint32(nil, uint32(headers+len(first)+len(second))) + buf = binary.LittleEndian.AppendUint32(buf, 3) + buf = binary.LittleEndian.AppendUint32(buf, 1) + buf = binary.LittleEndian.AppendUint32(buf, headers) + buf = binary.LittleEndian.AppendUint32(buf, 2) + buf = binary.LittleEndian.AppendUint32(buf, uint32(headers+len(first))) + buf = binary.LittleEndian.AppendUint32(buf, 3) + buf = binary.LittleEndian.AppendUint32(buf, 0xFFFFFFFF) + buf = append(buf, first...) + buf = append(buf, second...) + + require.Equal(t, map[uint32]string{ + 1: "Anzahl derzeit geöffneter Dateien", + 2: "Anzahl von Strukturverbindungen", + }, parseCounterNames(buf)) +}