From b055edf8020b2b526d967269cf3bb82249796e92 Mon Sep 17 00:00:00 2001 From: Wes Tarle Date: Sat, 21 Mar 2026 13:53:19 -0400 Subject: [PATCH 1/5] feat: implement full metric recording with context and error attributes --- go.work.sum | 30 +++++ v2/telemetry.go | 98 ++++++++++++++++ v2/telemetry_test.go | 273 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 387 insertions(+), 14 deletions(-) diff --git a/go.work.sum b/go.work.sum index 22592025..b57a805e 100644 --- a/go.work.sum +++ b/go.work.sum @@ -36,6 +36,7 @@ cloud.google.com/go/aiplatform v1.74.0/go.mod h1:hVEw30CetNut5FrblYd1AJUWRVSIjoy cloud.google.com/go/aiplatform v1.88.0/go.mod h1:UoB2KZD7L0wK/jhGE1ZMHGJKhdPsI2W80sU5NGGiQJA= cloud.google.com/go/aiplatform v1.99.0/go.mod h1:bOuku89ZrJVGkCUbEV3JHWRtOlneAXXMGMvaPhWVqfo= cloud.google.com/go/aiplatform v1.114.0/go.mod h1:W5yMrpIuHG/CSK8iF7XnwIfCJu6dcLRQ0cTqGR5vwwE= +cloud.google.com/go/aiplatform v1.120.0/go.mod h1:6mDthfmy0oS1EQhVFdijoxkVdI2+HIZkpuGTBpedeCg= cloud.google.com/go/analytics v0.21.2/go.mod h1:U8dcUtmDmjrmUTnnnRnI4m6zKn/yaA5N9RlEkYFHpQo= cloud.google.com/go/analytics v0.23.2/go.mod h1:vtE3olAXZ6edJYk1UOndEs6EfaEc9T2B28Y4G5/a7Fo= cloud.google.com/go/analytics v0.23.6/go.mod h1:cFz5GwWHrWQi8OHKP9ep3Z4pvHgGcG9lPnFQ+8kXsNo= @@ -74,18 +75,21 @@ cloud.google.com/go/area120 v0.8.11/go.mod h1:VBxJejRAJqeuzXQBbh5iHBYUkIjZk5UzFZ cloud.google.com/go/area120 v0.9.3/go.mod h1:F3vxS/+hqzrjJo55Xvda3Jznjjbd+4Foo43SN5eMd8M= cloud.google.com/go/area120 v0.9.6/go.mod h1:qKSokqe0iTmwBDA3tbLWonMEnh0pMAH4YxiceiHUed4= cloud.google.com/go/area120 v0.9.7/go.mod h1:5nJ0yksmjOMfc4Zpk+okWfJ3A1004FvB82rfia+ZLaY= +cloud.google.com/go/area120 v0.10.0/go.mod h1:Xg3fKl4xU3UVai9wsI1FXwNU8wSCDYT7dFZfwJKViAM= cloud.google.com/go/artifactregistry v1.14.1/go.mod h1:nxVdG19jTaSTu7yA7+VbWL346r3rIdkZ142BSQqhn5E= cloud.google.com/go/artifactregistry v1.14.9/go.mod h1:n2OsUqbYoUI2KxpzQZumm6TtBgtRf++QulEohdnlsvI= cloud.google.com/go/artifactregistry v1.14.13/go.mod h1:zQ/T4xoAFPtcxshl+Q4TJBgsy7APYR/BLd2z3xEAqRA= cloud.google.com/go/artifactregistry v1.16.1/go.mod h1:sPvFPZhfMavpiongKwfg93EOwJ18Tnj9DIwTU9xWUgs= cloud.google.com/go/artifactregistry v1.17.1/go.mod h1:06gLv5QwQPWtaudI2fWO37gfwwRUHwxm3gA8Fe568Hc= cloud.google.com/go/artifactregistry v1.19.0/go.mod h1:UEAPCgHDFC1q+A8nnVxXHPEy9KCVOeavFBF1fEChQvU= +cloud.google.com/go/artifactregistry v1.20.0/go.mod h1:0G9wdbGyDFkvrYH+2AlQs9MuTJdbY8Vg45M8VjlI8rc= cloud.google.com/go/asset v1.14.1/go.mod h1:4bEJ3dnHCqWCDbWJ/6Vn7GVI9LerSi7Rfdi03hd+WTQ= cloud.google.com/go/asset v1.19.1/go.mod h1:kGOS8DiCXv6wU/JWmHWCgaErtSZ6uN5noCy0YwVaGfs= cloud.google.com/go/asset v1.19.5/go.mod h1:sqyLOYaLLfc4ACcn3YxqHno+J7lRt9NJTdO50zCUcY0= cloud.google.com/go/asset v1.20.4/go.mod h1:DP09pZ+SoFWUZyPZx26xVroHk+6+9umnQv+01yfJxbM= cloud.google.com/go/asset v1.21.1/go.mod h1:7AzY1GCC+s1O73yzLM1IpHFLHz3ws2OigmCpOQHwebk= cloud.google.com/go/asset v1.22.0/go.mod h1:q80JP2TeWWzMCazYnrAfDf36aQKf1QiKzzpNLflJwf8= +cloud.google.com/go/asset v1.22.1/go.mod h1:NlvWwmca7CX6BIBEdRNxOocH6DowmBghAAHucOHuHng= cloud.google.com/go/assuredworkloads v1.11.1/go.mod h1:+F04I52Pgn5nmPG36CWFtxmav6+7Q+c5QyJoL18Lry0= cloud.google.com/go/assuredworkloads v1.11.7/go.mod h1:CqXcRH9N0KCDtHhFisv7kk+cl//lyV+pYXGi1h8rCEU= cloud.google.com/go/assuredworkloads v1.11.11/go.mod h1:vaYs6+MHqJvLKYgZBOsuuOhBgNNIguhRU0Kt7JTGcnI= @@ -150,12 +154,14 @@ cloud.google.com/go/bigquery v1.65.0/go.mod h1:9WXejQ9s5YkTW4ryDYzKXBooL78u5+akW cloud.google.com/go/bigquery v1.66.2/go.mod h1:+Yd6dRyW8D/FYEjUGodIbu0QaoEmgav7Lwhotup6njo= cloud.google.com/go/bigquery v1.69.0/go.mod h1:TdGLquA3h/mGg+McX+GsqG9afAzTAcldMjqhdjHTLew= cloud.google.com/go/bigquery v1.72.0/go.mod h1:GUbRtmeCckOE85endLherHD9RsujY+gS7i++c1CqssQ= +cloud.google.com/go/bigquery v1.74.0/go.mod h1:iViO7Cx3A/cRKcHNRsHB3yqGAMInFBswrE9Pxazsc90= cloud.google.com/go/bigtable v1.27.2-0.20240730134218-123c88616251/go.mod h1:avmXcmxVbLJAo9moICRYMgDyTTPoV0MA0lHKnyqV4fQ= cloud.google.com/go/bigtable v1.34.0/go.mod h1:p94uLf6cy6D73POkudMagaFF3x9c7ktZjRnOUVGjZAw= cloud.google.com/go/bigtable v1.35.0/go.mod h1:EabtwwmTcOJFXp+oMZAT/jZkyDIjNwrv53TrS4DGrrM= cloud.google.com/go/bigtable v1.37.0/go.mod h1:HXqddP6hduwzrtiTCqZPpj9ij4hGZb4Zy1WF/dT+yaU= cloud.google.com/go/bigtable v1.38.0/go.mod h1:o/lntJarF3Y5C0XYLMJLjLYwxaRbcrtM0BiV57ymXbI= cloud.google.com/go/bigtable v1.41.0/go.mod h1:JlaltP06LEFXaxQdZiarGR9tKsX/II0IkNAKMDrWspI= +cloud.google.com/go/bigtable v1.42.0/go.mod h1:oZ30nofVB6/UYGg7lBwGLWSea7NZUvw/WvBBgLY07xU= cloud.google.com/go/billing v1.16.0/go.mod h1:y8vx09JSSJG02k5QxbycNRrN7FGZB6F3CAcgum7jvGA= cloud.google.com/go/billing v1.18.5/go.mod h1:lHw7fxS6p7hLWEPzdIolMtOd0ahLwlokW06BzbleKP8= cloud.google.com/go/billing v1.18.9/go.mod h1:bKTnh8MBfCMUT1fzZ936CPN9rZG7ZEiHB2J3SjIjByc= @@ -238,6 +244,7 @@ cloud.google.com/go/container v1.42.2/go.mod h1:y71YW7uR5Ck+9Vsbst0AF2F3UMgqmsN4 cloud.google.com/go/container v1.42.4/go.mod h1:wf9lKc3ayWVbbV/IxKIDzT7E+1KQgzkzdxEJpj1pebE= cloud.google.com/go/container v1.44.0/go.mod h1:tVK2o4UZUTkg9WpBcgj4qRzwGA1dSFdWA3mil3YkLIQ= cloud.google.com/go/container v1.45.0/go.mod h1:eB6jUfJLjne9VsTDGcH7mnj6JyZK+KOUIA6KZnYE/ds= +cloud.google.com/go/container v1.46.0/go.mod h1:A7gMqdQduTk46+zssWDTKbGS2z46UsJNXfKqvMI1ZO4= cloud.google.com/go/containeranalysis v0.10.1/go.mod h1:Ya2jiILITMY68ZLPaogjmOMNkwsDrWBSTyBubGXO7j0= cloud.google.com/go/containeranalysis v0.11.6/go.mod h1:YRf7nxcTcN63/Kz9f86efzvrV33g/UV8JDdudRbYEUI= cloud.google.com/go/containeranalysis v0.12.1/go.mod h1:+/lcJIQSFt45TC0N9Nq7/dPbl0isk6hnC4EvBBqyXsM= @@ -263,6 +270,7 @@ cloud.google.com/go/dataform v0.9.8/go.mod h1:cGJdyVdunN7tkeXHPNosuMzmryx55mp6cI cloud.google.com/go/dataform v0.10.3/go.mod h1:8SruzxHYCxtvG53gXqDZvZCx12BlsUchuV/JQFtyTCw= cloud.google.com/go/dataform v0.12.0/go.mod h1:PuDIEY0lSVuPrZqcFji1fmr5RRvz3DGz4YP/cONc8g4= cloud.google.com/go/dataform v0.12.1/go.mod h1:atGS8ReRjfNDUQib0X/o/7Gi2bqHI2G7/J86LKiGimE= +cloud.google.com/go/dataform v0.13.0/go.mod h1:U3fqrPY5jAcFh1a8rQb4a+PQ7zKlc5qfgotFZ+luKPo= cloud.google.com/go/datafusion v1.7.1/go.mod h1:KpoTBbFmoToDExJUso/fcCiguGDk7MEzOWXUsJo0wsI= cloud.google.com/go/datafusion v1.7.7/go.mod h1:qGTtQcUs8l51lFA9ywuxmZJhS4ozxsBSus6ItqCUWMU= cloud.google.com/go/datafusion v1.7.11/go.mod h1:aU9zoBHgYmoPp4dzccgm/Gi4xWDMXodSZlNZ4WNeptw= @@ -291,6 +299,7 @@ cloud.google.com/go/dataproc/v2 v2.11.0/go.mod h1:9vgGrn57ra7KBqz+B2KD+ltzEXvnHA cloud.google.com/go/dataproc/v2 v2.11.2/go.mod h1:xwukBjtfiO4vMEa1VdqyFLqJmcv7t3lo+PbLDcTEw+g= cloud.google.com/go/dataproc/v2 v2.14.0/go.mod h1:AqfdObN5w70H7meRXZOEY52WMK4yMrLtiOd9kROahSM= cloud.google.com/go/dataproc/v2 v2.15.0/go.mod h1:tSdkodShfzrrUNPDVEL6MdH9/mIEvp/Z9s9PBdbsZg8= +cloud.google.com/go/dataproc/v2 v2.16.0/go.mod h1:HlzFg8k1SK+bJN3Zsy2z5g6OZS1D4DYiDUgJtF0gJnE= cloud.google.com/go/dataqna v0.8.1/go.mod h1:zxZM0Bl6liMePWsHA8RMGAfmTG34vJMapbHAxQ5+WA8= cloud.google.com/go/dataqna v0.8.7/go.mod h1:hvxGaSvINAVH5EJJsONIwT1y+B7OQogjHPjizOFoWOo= cloud.google.com/go/dataqna v0.8.11/go.mod h1:74Icl1oFKKZXPd+W7YDtqJLa+VwLV6wZ+UF+sHo2QZQ= @@ -302,6 +311,7 @@ cloud.google.com/go/datastore v1.12.1/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1 cloud.google.com/go/datastore v1.17.1/go.mod h1:mtzZ2HcVtz90OVrEXXGDc2pO4NM1kiBQy8YV4qGe0ZM= cloud.google.com/go/datastore v1.20.0/go.mod h1:uFo3e+aEpRfHgtp5pp0+6M0o147KoPaYNaPAKpfh8Ew= cloud.google.com/go/datastore v1.21.0/go.mod h1:9l+KyAHO+YVVcdBbNQZJu8svF17Nw5sMKuFR0LYf1nY= +cloud.google.com/go/datastore v1.22.0/go.mod h1:aopSX+Whx0lHspWWBj+AjWt68/zjYsPfDe3LjWtqZg8= cloud.google.com/go/datastream v1.9.1/go.mod h1:hqnmr8kdUBmrnk65k5wNRoHSCYksvpdZIcZIEl8h43Q= cloud.google.com/go/datastream v1.10.6/go.mod h1:lPeXWNbQ1rfRPjBFBLUdi+5r7XrniabdIiEaCaAU55o= cloud.google.com/go/datastream v1.10.10/go.mod h1:NqchuNjhPlISvWbk426/AU/S+Kgv7srlID9P5XOAbtg= @@ -325,6 +335,7 @@ cloud.google.com/go/dialogflow v1.66.0/go.mod h1:BPiRTnnXP/tHLot5h/U62Xcp+i6ekRj cloud.google.com/go/dialogflow v1.68.2/go.mod h1:E0Ocrhf5/nANZzBju8RX8rONf0PuIvz2fVj3XkbAhiY= cloud.google.com/go/dialogflow v1.69.0/go.mod h1:+2drAzrguQ8vltf6qn6foBPHrT/fFa1S3FQ40byV2WU= cloud.google.com/go/dialogflow v1.74.0/go.mod h1:jlKHmd3/KdvWWhGZjoCnWQAQNOMHOhDK6DQ430p3T1I= +cloud.google.com/go/dialogflow v1.76.0/go.mod h1:mdLkMmSCghfcP85X9dFBlirC1OssS65KE5hrrSz2GXY= cloud.google.com/go/dlp v1.10.1/go.mod h1:IM8BWz1iJd8njcNcG0+Kyd9OPnqnRNkDV8j42VT5KOI= cloud.google.com/go/dlp v1.14.0/go.mod h1:4fvEu3EbLsHrgH3QFdFlTNIiCP5mHwdYhS/8KChDIC4= cloud.google.com/go/dlp v1.15.0/go.mod h1:LtPZxZAenBXKzvWIOB2hdHIXuEcK0wW0En8//u+/nNA= @@ -340,6 +351,7 @@ cloud.google.com/go/documentai v1.35.1/go.mod h1:WJjwUAQfwQPJORW8fjz7RODprMULDzE cloud.google.com/go/documentai v1.35.2/go.mod h1:oh/0YXosgEq3hVhyH4ZQ7VNXPaveRO4eLVM3tBSZOsI= cloud.google.com/go/documentai v1.37.0/go.mod h1:qAf3ewuIUJgvSHQmmUWvM3Ogsr5A16U2WPHmiJldvLA= cloud.google.com/go/documentai v1.39.0/go.mod h1:KmlLO93F7GRU8dENXRxvt+7V8o7eCG6Y6WDitKbcYJs= +cloud.google.com/go/documentai v1.42.0/go.mod h1:CABOUzRNOuvb/QwJS2LS80Hpqbu3UW2afyRKTYuW7bo= cloud.google.com/go/domains v0.9.1/go.mod h1:aOp1c0MbejQQ2Pjf1iJvnVyT+z6R6s8pX66KaCSDYfE= cloud.google.com/go/domains v0.9.7/go.mod h1:u/yVf3BgfPJW3QDZl51qTJcDXo9PLqnEIxfGmGgbHEc= cloud.google.com/go/domains v0.9.11/go.mod h1:efo5552kUyxsXEz30+RaoIS2lR7tp3M/rhiYtKXkhkk= @@ -456,6 +468,7 @@ cloud.google.com/go/kms v1.20.4/go.mod h1:gPLsp1r4FblUgBYPOcvI/bUPpdMg2Jm1ZVKU4t cloud.google.com/go/kms v1.21.0/go.mod h1:zoFXMhVVK7lQ3JC9xmhHMoQhnjEDZFoLAr5YMwzBLtk= cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= cloud.google.com/go/kms v1.25.0/go.mod h1:XIdHkzfj0bUO3E+LvwPg+oc7s58/Ns8Nd8Sdtljihbk= +cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= cloud.google.com/go/language v1.10.1/go.mod h1:CPp94nsdVNiQEt1CNjF5WkTcisLiHPyIbMhvR8H2AW0= cloud.google.com/go/language v1.12.5/go.mod h1:w/6a7+Rhg6Bc2Uzw6thRdKKNjnOzfKTJuxzD0JZZ0nM= cloud.google.com/go/language v1.12.9/go.mod h1:B9FbD17g1EkilctNGUDAdSrBHiFOlKNErLljO7jplDU= @@ -473,6 +486,7 @@ cloud.google.com/go/logging v1.10.0/go.mod h1:EHOwcxlltJrYGqMGfghSet736KR3hX1MAj cloud.google.com/go/logging v1.11.0/go.mod h1:5LDiJC/RxTt+fHc1LAt20R9TKiUTReDg6RuuFOZ67+A= cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ= cloud.google.com/go/longrunning v0.5.0/go.mod h1:0JNuqRShmscVAhIACGtskSAWtqtOoPkwP0YF1oVEchc= cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= @@ -497,6 +511,7 @@ cloud.google.com/go/maps v1.19.0/go.mod h1:goHUXrmzoZvQjUVd0KGhH8t3AYRm17P8b+fsy cloud.google.com/go/maps v1.21.0/go.mod h1:cqzZ7+DWUKKbPTgqE+KuNQtiCRyg/o7WZF9zDQk+HQs= cloud.google.com/go/maps v1.22.0/go.mod h1:TAt/cYHndJQBrir8DN8OHiS0HvKwsBTqDGRfAtLIulU= cloud.google.com/go/maps v1.26.0/go.mod h1:+auempdONAP8emtm48aCfNo1ZC+3CJniRA1h8J4u7bY= +cloud.google.com/go/maps v1.29.0/go.mod h1:FNATcM5ziB2TDE2IVWH4f/yeXc+SbUk1X+bmKjR8HEA= cloud.google.com/go/mediatranslation v0.8.1/go.mod h1:L/7hBdEYbYHQJhX2sldtTO5SZZ1C1vkapubj0T2aGig= cloud.google.com/go/mediatranslation v0.8.7/go.mod h1:6eJbPj1QJwiCP8R4K413qMx6ZHZJUi9QFpApqY88xWU= cloud.google.com/go/mediatranslation v0.8.11/go.mod h1:3sNEm0fx61eHk7rfzBzrljVV9XKr931xI3OFacQBVFg= @@ -529,6 +544,7 @@ cloud.google.com/go/networkconnectivity v1.16.1/go.mod h1:GBC1iOLkblcnhcnfRV92j4 cloud.google.com/go/networkconnectivity v1.17.1/go.mod h1:DTZCq8POTkHgAlOAAEDQF3cMEr/B9k1ZbpklqvHEBtg= cloud.google.com/go/networkconnectivity v1.18.0/go.mod h1:8MFjpAsCqTKUO+U5y9C6iGAsq2KkrfpQ43/XbqSbICc= cloud.google.com/go/networkconnectivity v1.20.0/go.mod h1:9MzGwD4ljiq+Z2Pg3ue27OEewCuHz7IUfw1fITrIdSw= +cloud.google.com/go/networkconnectivity v1.21.0/go.mod h1:XC1UJ+tqBsLWz73dqrMc7kUvdTv0FIxtDGv6YntTBO0= cloud.google.com/go/networkmanagement v1.8.0/go.mod h1:Ho/BUGmtyEqrttTgWEe7m+8vDdK74ibQc+Be0q7Fof0= cloud.google.com/go/networkmanagement v1.13.2/go.mod h1:24VrV/5HFIOXMEtVQEUoB4m/w8UWvUPAYjfnYZcBc4c= cloud.google.com/go/networkmanagement v1.13.6/go.mod h1:WXBijOnX90IFb6sberjnGrVtZbgDNcPDUYOlGXmG8+4= @@ -537,6 +553,7 @@ cloud.google.com/go/networkmanagement v1.18.0/go.mod h1:yTxpAFuvQOOKgL3W7+k2Rp1b cloud.google.com/go/networkmanagement v1.19.1/go.mod h1:icgk265dNnilxQzpr6rO9WuAuuCmUOqq9H6WBeM2Af4= cloud.google.com/go/networkmanagement v1.20.0/go.mod h1:t/GQe1ICzaxeETse/6EPEjmjOr9zGyNImVLlxAX+YB4= cloud.google.com/go/networkmanagement v1.21.0/go.mod h1:clG/5Yt0wQ57qSH6Yh7oehQYlobHw3F6nb3Pn4ig5hU= +cloud.google.com/go/networkmanagement v1.23.0/go.mod h1:QTYCWp5UxUnU280SqF7AX/mf6NhsqKblmLeCALQmx5c= cloud.google.com/go/networksecurity v0.9.1/go.mod h1:MCMdxOKQ30wsBI1eI659f9kEp4wuuAueoC9AJKSPWZQ= cloud.google.com/go/networksecurity v0.9.7/go.mod h1:aB6UiPnh/l32+TRvgTeOxVRVAHAFFqvK+ll3idU5BoY= cloud.google.com/go/networksecurity v0.9.11/go.mod h1:4xbpOqCwplmFgymAjPFM6ZIplVC6+eQ4m7sIiEq9oJA= @@ -575,6 +592,7 @@ cloud.google.com/go/osconfig v1.14.3/go.mod h1:9D2MS1Etne18r/mAeW5jtto3toc9H1qu9 cloud.google.com/go/osconfig v1.14.6/go.mod h1:LS39HDBH0IJDFgOUkhSZUHFQzmcWaCpYXLrc3A4CVzI= cloud.google.com/go/osconfig v1.15.0/go.mod h1:0nY8bfGKWJB0Ft5bBKd2zMkjT4Uf0rM3NBFrAGUv1Lk= cloud.google.com/go/osconfig v1.15.1/go.mod h1:NegylQQl0+5m+I+4Ey/g3HGeQxKkncQ1q+Il4DZ8PME= +cloud.google.com/go/osconfig v1.16.0/go.mod h1:PRmLgZ1loD1hGaqnTBww1nETbqcqAvmTQOLYiIZ7Nvk= cloud.google.com/go/oslogin v1.10.1/go.mod h1:x692z7yAue5nE7CsSnoG0aaMbNoRJRXO4sn73R+ZqAs= cloud.google.com/go/oslogin v1.13.3/go.mod h1:WW7Rs1OJQ1iSUckZDilvNBSNPE8on740zF+4ZDR4o8U= cloud.google.com/go/oslogin v1.13.7/go.mod h1:xq027cL0fojpcEcpEQdWayiDn8tIx3WEFYMM6+q7U+E= @@ -654,6 +672,7 @@ cloud.google.com/go/retail v1.19.2/go.mod h1:71tRFYAcR4MhrZ1YZzaJxr030LvaZiIcupH cloud.google.com/go/retail v1.20.0/go.mod h1:1CXWDZDJTOsK6lPjkv67gValP9+h1TMadTC9NpFFr9s= cloud.google.com/go/retail v1.24.0/go.mod h1:pvLFfRzTnqGf3yHNnIq4R+A5nfEy56SYE9optVPOuSk= cloud.google.com/go/retail v1.25.1/go.mod h1:J75G8pd+DH0SHueL9IJw7Y5d2VhTsjFsk+F1t9f8jXc= +cloud.google.com/go/retail v1.26.0/go.mod h1:gMfh6s174Mvy1rK4g50J9TH5sRim8px+Krml25kdrqo= cloud.google.com/go/run v0.9.0/go.mod h1:Wwu+/vvg8Y+JUApMwEDfVfhetv30hCG4ZwDR/IXl2Qg= cloud.google.com/go/run v1.2.0/go.mod h1:36V1IlDzQ0XxbQjUx6IYbw8H3TJnWvhii963WW3B/bo= cloud.google.com/go/run v1.3.7/go.mod h1:iEUflDx4Js+wK0NzF5o7hE9Dj7QqJKnRj0/b6rhVq20= @@ -713,6 +732,7 @@ cloud.google.com/go/spanner v1.76.1/go.mod h1:YtwoE+zObKY7+ZeDCBtZ2ukM+1/iPaMfUM cloud.google.com/go/spanner v1.82.0/go.mod h1:BzybQHFQ/NqGxvE/M+/iU29xgutJf7Q85/4U9RWMto0= cloud.google.com/go/spanner v1.84.1/go.mod h1:3GMEIjOcXINJSvb42H3M6TdlGCDzaCFpiiNQpjHPlCM= cloud.google.com/go/spanner v1.87.0/go.mod h1:tcj735Y2aqphB6/l+X5MmwG4NnV+X1NJIbFSZGaHYXw= +cloud.google.com/go/spanner v1.88.0/go.mod h1:MzulBwuuYwQUVdkZXBBFapmXee3N+sQrj2T/yup6uEE= cloud.google.com/go/speech v1.17.1/go.mod h1:8rVNzU43tQvxDaGvqOhpDqgkJTFowBpDvCJ14kGlJYo= cloud.google.com/go/speech v1.23.1/go.mod h1:UNgzNxhNBuo/OxpF1rMhA/U2rdai7ILL6PBXFs70wq0= cloud.google.com/go/speech v1.24.0/go.mod h1:HcVyIh5jRXM5zDMcbFCW+DF2uK/MSGN6Rastt6bj1ic= @@ -720,6 +740,7 @@ cloud.google.com/go/speech v1.26.0/go.mod h1:78bqDV2SgwFlP/M4n3i3PwLthFq6ta7qmyG cloud.google.com/go/speech v1.27.1/go.mod h1:efCfklHFL4Flxcdt9gpEMEJh9MupaBzw3QiSOVeJ6ck= cloud.google.com/go/speech v1.28.0/go.mod h1:hJf6oa+1rzCW/CeDE/qCXedV20B2TXEUje5iaGwW+JI= cloud.google.com/go/speech v1.29.0/go.mod h1:wtUmIS/h0ZYU6cPA9klcyST3f6i2FdnvNDqENjrRDds= +cloud.google.com/go/speech v1.30.0/go.mod h1:F2+NJujR8uzDLd6bwy5kgtVycxvEq06nzvzz5eQ/gMo= cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= cloud.google.com/go/storage v1.41.0/go.mod h1:J1WCa/Z2FcgdEDuPUY8DxT5I+d9mFKsCepp5vR6Sq80= cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= @@ -1064,6 +1085,7 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632 golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= @@ -1079,6 +1101,7 @@ golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1180,9 +1203,11 @@ golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= @@ -1201,6 +1226,7 @@ golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -1242,6 +1268,7 @@ golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1316,6 +1343,7 @@ google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925 google.golang.org/genproto/googleapis/bytestream v0.0.0-20250818200422-3122310a409c/go.mod h1:1kGGe25NDrNJYgta9Rp2QLLXWS1FLVMMXNvihbhK0iE= google.golang.org/genproto/googleapis/bytestream v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:Tej9lWiwVvQJP+b43pjJIsr/3mZycXWCIyoiXmbFf40= google.golang.org/genproto/googleapis/bytestream v0.0.0-20260226221140-a57be14db171/go.mod h1:9amqk/8LQWEC4RjyUxMx1DebyQ7hZB9gvl67bHmgZ2E= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260311181403-84a4fc48630c/go.mod h1:9amqk/8LQWEC4RjyUxMx1DebyQ7hZB9gvl67bHmgZ2E= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o= @@ -1350,6 +1378,7 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -1374,6 +1403,7 @@ google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeB google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= diff --git a/v2/telemetry.go b/v2/telemetry.go index 1e0320a2..2d6c9157 100644 --- a/v2/telemetry.go +++ b/v2/telemetry.go @@ -31,12 +31,19 @@ package gax import ( "context" + "errors" + "fmt" "log/slog" "sync" + "time" + "github.com/googleapis/gax-go/v2/apierror" + "github.com/googleapis/gax-go/v2/callctx" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // TransportTelemetryData contains mutable telemetry information that the transport @@ -293,3 +300,94 @@ func (cm *ClientMetrics) attributes() []attribute.KeyValue { } return cm.get().attr } + +var codeToStr = [...]string{ + "OK", // codes.OK = 0 + "CANCELED", // codes.Canceled = 1 + "UNKNOWN", // codes.Unknown = 2 + "INVALID_ARGUMENT", // codes.InvalidArgument = 3 + "DEADLINE_EXCEEDED", // codes.DeadlineExceeded = 4 + "NOT_FOUND", // codes.NotFound = 5 + "ALREADY_EXISTS", // codes.AlreadyExists = 6 + "PERMISSION_DENIED", // codes.PermissionDenied = 7 + "RESOURCE_EXHAUSTED", // codes.ResourceExhausted = 8 + "FAILED_PRECONDITION", // codes.FailedPrecondition = 9 + "ABORTED", // codes.Aborted = 10 + "OUT_OF_RANGE", // codes.OutOfRange = 11 + "UNIMPLEMENTED", // codes.Unimplemented = 12 + "INTERNAL", // codes.Internal = 13 + "UNAVAILABLE", // codes.Unavailable = 14 + "DATA_LOSS", // codes.DataLoss = 15 + "UNAUTHENTICATED", // codes.Unauthenticated = 16 +} + +// GRPCCodeToStatusString converts a codes.Code to its string representation. +// Experimental: This function is experimental and may be modified or removed in future versions, +// regardless of any other documented package stability guarantees. +func GRPCCodeToStatusString(c codes.Code) string { + if int(c) >= 0 && int(c) < len(codeToStr) { + return codeToStr[c] + } + return "UNKNOWN" +} + +// TelemetryErrorType extracts the error type and status code from an error. +// Experimental: This function is experimental and may be modified or removed in future versions, +// regardless of any other documented package stability guarantees. +func TelemetryErrorType(ctx context.Context, err error) (string, string) { + if err == nil { + return "", "OK" + } + + st, ok := status.FromError(err) + rpcStatusCode := GRPCCodeToStatusString(st.Code()) + + var errType string + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + errType = "CLIENT_TIMEOUT" + } else if errors.Is(ctx.Err(), context.Canceled) { + errType = "CLIENT_CANCELLED" + } else if !ok || st.Code() == codes.Unknown || st.Code() == codes.Internal { + errType = fmt.Sprintf("%T", err) + } else { + errType = rpcStatusCode + } + + if apierr, ok := apierror.FromError(err); ok { + if reason := apierr.Reason(); reason != "" { + errType = reason + } + } + + return errType, rpcStatusCode +} + +// recordMetric records a duration measurement for the configured metric. +func recordMetric(ctx context.Context, settings CallSettings, d time.Duration, err error) { + if settings.clientMetrics == nil || settings.clientMetrics.durationHistogram() == nil { + return + } + + // Use context.WithoutCancel to ensure metric records even if context is canceled + // preserving any trace context that might be required for exemplars. + recordCtx := context.WithoutCancel(ctx) + + // Pre-allocate to avoid repeated appends (5 is the max number of dynamic attributes added here) + attrs := make([]attribute.KeyValue, 0, len(settings.clientMetrics.attributes())+5) + attrs = append(attrs, settings.clientMetrics.attributes()...) + + errType, statusCode := TelemetryErrorType(ctx, err) + if errType != "" { + attrs = append(attrs, attribute.String("error.type", errType)) + } + attrs = append(attrs, attribute.String("rpc.response.status_code", statusCode)) + + if rpcMethod, ok := callctx.TelemetryFromContext(ctx, "rpc_method"); ok && rpcMethod != "" { + attrs = append(attrs, attribute.String("rpc.method", rpcMethod)) + } + if urlTemplate, ok := callctx.TelemetryFromContext(ctx, "url_template"); ok && urlTemplate != "" { + attrs = append(attrs, attribute.String("url.template", urlTemplate)) + } + + settings.clientMetrics.durationHistogram().Record(recordCtx, d.Seconds(), metric.WithAttributes(attrs...)) +} diff --git a/v2/telemetry_test.go b/v2/telemetry_test.go index a74ba03b..23d8d05c 100644 --- a/v2/telemetry_test.go +++ b/v2/telemetry_test.go @@ -32,15 +32,24 @@ package gax import ( "bytes" "context" + "errors" "log/slog" + "math" "os/exec" "strings" "testing" + "time" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/gax-go/v2/apierror" + "github.com/googleapis/gax-go/v2/callctx" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric/noop" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func TestNewClientMetrics(t *testing.T) { @@ -170,13 +179,8 @@ func TestNewClientMetrics(t *testing.T) { scopeAttrs[string(set.Key)] = set.Value.AsString() } - if len(scopeAttrs) != len(tt.wantScopeAttr) { - t.Errorf("expected %d scope attributes, got %d (%v)", len(tt.wantScopeAttr), len(scopeAttrs), scopeAttrs) - } - for wantK, wantV := range tt.wantScopeAttr { - if gotV, ok := scopeAttrs[wantK]; !ok || gotV != wantV { - t.Errorf("expected scope attribute %s=%s, got %s", wantK, wantV, gotV) - } + if diff := cmp.Diff(tt.wantScopeAttr, scopeAttrs); diff != "" { + t.Errorf("Scope attributes mismatch (-want +got):\n%s", diff) } // Verify Exact Datapoint Attributes from the collected metric @@ -194,13 +198,8 @@ func TestNewClientMetrics(t *testing.T) { dpAttrs[string(set.Key)] = set.Value.AsString() } - if len(dpAttrs) != len(tt.wantDataAttr) { - t.Errorf("expected %d datapoint attributes, got %d (%v)", len(tt.wantDataAttr), len(dpAttrs), dpAttrs) - } - for wantK, wantV := range tt.wantDataAttr { - if gotV, ok := dpAttrs[wantK]; !ok || gotV != wantV { - t.Errorf("expected datapoint attribute %s=%s, got %s", wantK, wantV, gotV) - } + if diff := cmp.Diff(tt.wantDataAttr, dpAttrs); diff != "" { + t.Errorf("DataPoint attributes mismatch (-want +got):\n%s", diff) } }) } @@ -338,3 +337,249 @@ func TestTransportTelemetry(t *testing.T) { t.Errorf("got.ResponseStatusCode() = %d, want %d", got.ResponseStatusCode(), 200) } } + +func TestRecordMetric(t *testing.T) { + // Helper to construct a real apierror.APIError with an ErrorInfo + st := status.New(codes.PermissionDenied, "disabled") + stWithDetails, _ := st.WithDetails(&errdetails.ErrorInfo{Reason: "SERVICE_DISABLED", Domain: "googleapis.com"}) + apiErr, _ := apierror.FromError(stWithDetails.Err()) + + tests := []struct { + name string + method string + template string + setupCtx func() (context.Context, context.CancelFunc) + err error + wantSum float64 + wantDataAttr map[string]string + nilMetrics bool + }{ + { + name: "nil_metrics", + setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, + nilMetrics: true, // Should return early and not panic + }, + { + name: "success", + method: "my.service.Method", + template: "/v1/test/{id}", + setupCtx: func() (context.Context, context.CancelFunc) { + return context.Background(), func() {} + }, + err: nil, + wantSum: 1.5, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "rpc.response.status_code": "OK", + "rpc.method": "my.service.Method", + "url.template": "/v1/test/{id}", + }, + }, + { + name: "error_cancelled", + method: "my.service.Method", + template: "/v1/test/{id}", + setupCtx: func() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx, cancel + }, + err: context.Canceled, + wantSum: 1.5, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "rpc.response.status_code": "UNKNOWN", + "error.type": "CLIENT_CANCELLED", + "rpc.method": "my.service.Method", + "url.template": "/v1/test/{id}", + }, + }, + { + name: "error_deadline", + method: "my.service.Method", + template: "/v1/test/{id}", + setupCtx: func() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithTimeout(context.Background(), 0) + return ctx, cancel + }, + err: context.DeadlineExceeded, + wantSum: 1.5, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "rpc.response.status_code": "UNKNOWN", + "error.type": "CLIENT_TIMEOUT", + "rpc.method": "my.service.Method", + "url.template": "/v1/test/{id}", + }, + }, + { + name: "error_apierror_reason", + method: "my.service.Method", + template: "/v1/test/{id}", + setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, + err: apiErr, + wantSum: 1.5, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "rpc.response.status_code": "PERMISSION_DENIED", + "error.type": "SERVICE_DISABLED", + "rpc.method": "my.service.Method", + "url.template": "/v1/test/{id}", + }, + }, + { + name: "error_unknown_type", + method: "my.service.Method", + template: "/v1/test/{id}", + setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, + err: errors.New("random io error"), + wantSum: 1.5, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "rpc.response.status_code": "UNKNOWN", + "error.type": "*errors.errorString", + "rpc.method": "my.service.Method", + "url.template": "/v1/test/{id}", + }, + }, + { + name: "error_invalid_grpc_code", + method: "my.service.Method", + template: "/v1/test/{id}", + setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, + err: status.Error(codes.Code(999), "unknown code"), + wantSum: 1.5, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "rpc.response.status_code": "UNKNOWN", + "error.type": "UNKNOWN", + "rpc.method": "my.service.Method", + "url.template": "/v1/test/{id}", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := tt.setupCtx() + defer cancel() + + if tt.method != "" { + ctx = callctx.WithTelemetryContext(ctx, "rpc_method", tt.method) + } + if tt.template != "" { + ctx = callctx.WithTelemetryContext(ctx, "url_template", tt.template) + } + + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + settings := CallSettings{} + if !tt.nilMetrics { + opts := []TelemetryOption{ + WithMeterProvider(provider), + WithTelemetryAttributes(map[string]string{ + ClientArtifact: "test-artifact", + ClientService: "test-service", + URLDomain: "test.domain", + RPCSystem: "grpc", + }), + } + cm := NewClientMetrics(opts...) + WithClientMetrics(cm).Resolve(&settings) + } + + dur := time.Duration(tt.wantSum * float64(time.Second)) + recordMetric(ctx, settings, dur, tt.err) + + var rm metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &rm); err != nil { + t.Fatalf("failed to collect metrics: %v", err) + } + + if tt.nilMetrics { + if len(rm.ScopeMetrics) > 0 { + t.Fatalf("expected 0 metrics recorded for nil clientMetrics") + } + return + } + + if len(rm.ScopeMetrics) == 0 { + t.Fatalf("expected at least 1 ScopeMetrics") + } + + scopeMetric := rm.ScopeMetrics[0] + if len(scopeMetric.Metrics) == 0 { + t.Fatalf("expected at least 1 Metric recorded") + } + + metric := scopeMetric.Metrics[0] + if metric.Name != metricName { + t.Errorf("expected metric.Name %q, got %q", metricName, metric.Name) + } + + histo, ok := metric.Data.(metricdata.Histogram[float64]) + if !ok { + t.Fatalf("expected metricdata.Histogram[float64], got %T", metric.Data) + } + + if len(histo.DataPoints) == 0 { + t.Fatalf("expected at least 1 DataPoint") + } + + point := histo.DataPoints[0] + + if math.Abs(point.Sum-tt.wantSum) > 1e-6 { + t.Errorf("expected float sum %f, got %f", tt.wantSum, point.Sum) + } + if point.Count != 1 { + t.Errorf("expected count 1, got %d", point.Count) + } + + wantScopeAttr := map[string]string{ + "gcp.client.service": "test-service", + } + gotScopeAttr := make(map[string]string) + for _, a := range scopeMetric.Scope.Attributes.ToSlice() { + gotScopeAttr[string(a.Key)] = a.Value.AsString() + } + + if diff := cmp.Diff(wantScopeAttr, gotScopeAttr); diff != "" { + t.Errorf("Scope attributes mismatch (-want +got):\n%s", diff) + } + + gotDataAttr := make(map[string]string) + for _, a := range point.Attributes.ToSlice() { + gotDataAttr[string(a.Key)] = a.Value.AsString() + } + + if diff := cmp.Diff(tt.wantDataAttr, gotDataAttr); diff != "" { + t.Errorf("DataPoint attributes mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestClientMetrics_NilReceiver(t *testing.T) { + var cm *ClientMetrics + if cm.durationHistogram() != nil { + t.Errorf("expected nil durationHistogram for nil receiver") + } + if cm.attributes() != nil { + t.Errorf("expected nil attributes for nil receiver") + } + + cm = &ClientMetrics{} // nil .get func + if cm.durationHistogram() != nil { + t.Errorf("expected nil durationHistogram for uninitialized ClientMetrics") + } + if cm.attributes() != nil { + t.Errorf("expected nil attributes for uninitialized ClientMetrics") + } +} From 80c44d0ed9406d36a1009c1da9ce881a0038ed30 Mon Sep 17 00:00:00 2001 From: Wes Tarle Date: Mon, 23 Mar 2026 21:44:27 -0400 Subject: [PATCH 2/5] refactor: return TelemetryErrorInfo struct from TelemetryErrorType --- v2/telemetry.go | 81 ++++++++++++++++++++---- v2/telemetry_test.go | 147 +++++++++++++++++++++++-------------------- 2 files changed, 146 insertions(+), 82 deletions(-) diff --git a/v2/telemetry.go b/v2/telemetry.go index 2d6c9157..deefc244 100644 --- a/v2/telemetry.go +++ b/v2/telemetry.go @@ -321,45 +321,100 @@ var codeToStr = [...]string{ "UNAUTHENTICATED", // codes.Unauthenticated = 16 } -// GRPCCodeToStatusString converts a codes.Code to its string representation. +// grpcCodeToStatusString converts a codes.Code to its string representation. // Experimental: This function is experimental and may be modified or removed in future versions, // regardless of any other documented package stability guarantees. -func GRPCCodeToStatusString(c codes.Code) string { +func grpcCodeToStatusString(c codes.Code) string { if int(c) >= 0 && int(c) < len(codeToStr) { return codeToStr[c] } return "UNKNOWN" } -// TelemetryErrorType extracts the error type and status code from an error. +// TelemetryErrorInfo contains the mapped error type and status code, as well as +// additional details like status message, domain, and metadata, extracted from an error +// for telemetry purposes. +type TelemetryErrorInfo struct { + // ErrorType is a mapped string for the error type. + ErrorType string + // StatusCode is the string representation of the RPC status code. + StatusCode string + // StatusMessage is the raw message from the error. + StatusMessage string + // Domain is the domain of the error, extracted from an ErrorInfo, if available. + Domain string + // Metadata is the metadata of the error, extracted from an ErrorInfo, if available. + Metadata map[string]string + + // _ struct{} prevents unkeyed struct literals, ensuring backwards + // compatibility when new fields are added in the future. + _ struct{} +} + +// ParseTelemetryErrorInfo extracts the error type and status code from an error. +// For stability, it maps client-side cancellations, timeouts, and known gRPC +// status codes to standard string literals (e.g., "CLIENT_TIMEOUT", +// "PERMISSION_DENIED"), and falls back to %T for unhandled types. If an +// apierror.APIError is found, it will use its fine-grained Reason() (e.g., +// "SERVICE_DISABLED"). +// // Experimental: This function is experimental and may be modified or removed in future versions, // regardless of any other documented package stability guarantees. -func TelemetryErrorType(ctx context.Context, err error) (string, string) { +func ParseTelemetryErrorInfo(ctx context.Context, err error) TelemetryErrorInfo { if err == nil { - return "", "OK" + return TelemetryErrorInfo{ErrorType: "", StatusCode: "OK"} } st, ok := status.FromError(err) - rpcStatusCode := GRPCCodeToStatusString(st.Code()) + rpcStatusCode := grpcCodeToStatusString(st.Code()) var errType string + // 1. Check if the local context expired or was cancelled. This is the only + // reliable way to distinguish a local client timeout from a server timeout + // because gRPC does not wrap context errors in its status.Error types. if errors.Is(ctx.Err(), context.DeadlineExceeded) { errType = "CLIENT_TIMEOUT" } else if errors.Is(ctx.Err(), context.Canceled) { errType = "CLIENT_CANCELLED" } else if !ok || st.Code() == codes.Unknown || st.Code() == codes.Internal { + // 2. If the error isn't a context breakdown and the gRPC framework + // doesn't "understand" it (returning ok=false or a generic catch-all + // bucket like Unknown/Internal), we "pack" the actual Go error type + // name into error.type (e.g., "*net.OpError"). This is per the error.type + // [spec](https://opentelemetry.io/docs/specs/semconv/registry/attributes/error/#error-type). + // "When error.type is set to a type (e.g., an exception type), its canonical + // class name identifying the type within the artifact SHOULD be used." errType = fmt.Sprintf("%T", err) } else { + // 3. Otherwise, it is a well-understood gRPC protocol error (e.g., + // PERMISSION_DENIED) likely returned by the server. errType = rpcStatusCode } - if apierr, ok := apierror.FromError(err); ok { - if reason := apierr.Reason(); reason != "" { + var msg, domain string + var metadata map[string]string + if ok { + msg = st.Message() + } else { + msg = err.Error() + } + + if parsedErr, parsedOk := apierror.ParseError(err, false); parsedOk { + // If there's an actionable error, the reason takes precedence over our calculated error type. + if reason := parsedErr.Reason(); reason != "" { errType = reason } + domain = parsedErr.Domain() + metadata = parsedErr.Metadata() } - return errType, rpcStatusCode + return TelemetryErrorInfo{ + ErrorType: errType, + StatusCode: rpcStatusCode, + StatusMessage: msg, + Domain: domain, + Metadata: metadata, + } } // recordMetric records a duration measurement for the configured metric. @@ -376,11 +431,11 @@ func recordMetric(ctx context.Context, settings CallSettings, d time.Duration, e attrs := make([]attribute.KeyValue, 0, len(settings.clientMetrics.attributes())+5) attrs = append(attrs, settings.clientMetrics.attributes()...) - errType, statusCode := TelemetryErrorType(ctx, err) - if errType != "" { - attrs = append(attrs, attribute.String("error.type", errType)) + errInfo := ParseTelemetryErrorInfo(ctx, err) + if errInfo.ErrorType != "" { + attrs = append(attrs, attribute.String("error.type", errInfo.ErrorType)) } - attrs = append(attrs, attribute.String("rpc.response.status_code", statusCode)) + attrs = append(attrs, attribute.String("rpc.response.status_code", errInfo.StatusCode)) if rpcMethod, ok := callctx.TelemetryFromContext(ctx, "rpc_method"); ok && rpcMethod != "" { attrs = append(attrs, attribute.String("rpc.method", rpcMethod)) diff --git a/v2/telemetry_test.go b/v2/telemetry_test.go index 23d8d05c..421cdffe 100644 --- a/v2/telemetry_test.go +++ b/v2/telemetry_test.go @@ -338,7 +338,7 @@ func TestTransportTelemetry(t *testing.T) { } } -func TestRecordMetric(t *testing.T) { +func TestParseTelemetryErrorInfo(t *testing.T) { // Helper to construct a real apierror.APIError with an ErrorInfo st := status.New(codes.PermissionDenied, "disabled") stWithDetails, _ := st.WithDetails(&errdetails.ErrorInfo{Reason: "SERVICE_DISABLED", Domain: "googleapis.com"}) @@ -346,119 +346,129 @@ func TestRecordMetric(t *testing.T) { tests := []struct { name string - method string - template string setupCtx func() (context.Context, context.CancelFunc) err error - wantSum float64 - wantDataAttr map[string]string - nilMetrics bool + wantInfo TelemetryErrorInfo }{ - { - name: "nil_metrics", - setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, - nilMetrics: true, // Should return early and not panic - }, { name: "success", - method: "my.service.Method", - template: "/v1/test/{id}", - setupCtx: func() (context.Context, context.CancelFunc) { - return context.Background(), func() {} - }, - err: nil, - wantSum: 1.5, - wantDataAttr: map[string]string{ - "url.domain": "test.domain", - "rpc.system.name": "grpc", - "rpc.response.status_code": "OK", - "rpc.method": "my.service.Method", - "url.template": "/v1/test/{id}", - }, + setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, + err: nil, + wantInfo: TelemetryErrorInfo{ErrorType: "", StatusCode: "OK"}, }, { - name: "error_cancelled", - method: "my.service.Method", - template: "/v1/test/{id}", + name: "error_cancelled", setupCtx: func() (context.Context, context.CancelFunc) { ctx, cancel := context.WithCancel(context.Background()) cancel() return ctx, cancel }, - err: context.Canceled, - wantSum: 1.5, - wantDataAttr: map[string]string{ - "url.domain": "test.domain", - "rpc.system.name": "grpc", - "rpc.response.status_code": "UNKNOWN", - "error.type": "CLIENT_CANCELLED", - "rpc.method": "my.service.Method", - "url.template": "/v1/test/{id}", + err: context.Canceled, + wantInfo: TelemetryErrorInfo{ + ErrorType: "CLIENT_CANCELLED", + StatusCode: "UNKNOWN", + StatusMessage: "context canceled", }, }, { - name: "error_deadline", - method: "my.service.Method", - template: "/v1/test/{id}", + name: "error_deadline", setupCtx: func() (context.Context, context.CancelFunc) { ctx, cancel := context.WithTimeout(context.Background(), 0) return ctx, cancel }, - err: context.DeadlineExceeded, - wantSum: 1.5, - wantDataAttr: map[string]string{ - "url.domain": "test.domain", - "rpc.system.name": "grpc", - "rpc.response.status_code": "UNKNOWN", - "error.type": "CLIENT_TIMEOUT", - "rpc.method": "my.service.Method", - "url.template": "/v1/test/{id}", + err: context.DeadlineExceeded, + wantInfo: TelemetryErrorInfo{ + ErrorType: "CLIENT_TIMEOUT", + StatusCode: "UNKNOWN", + StatusMessage: "context deadline exceeded", }, }, { name: "error_apierror_reason", - method: "my.service.Method", - template: "/v1/test/{id}", setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, err: apiErr, - wantSum: 1.5, - wantDataAttr: map[string]string{ - "url.domain": "test.domain", - "rpc.system.name": "grpc", - "rpc.response.status_code": "PERMISSION_DENIED", - "error.type": "SERVICE_DISABLED", - "rpc.method": "my.service.Method", - "url.template": "/v1/test/{id}", + wantInfo: TelemetryErrorInfo{ + ErrorType: "SERVICE_DISABLED", + StatusCode: "PERMISSION_DENIED", + StatusMessage: "disabled", + Domain: "googleapis.com", + Metadata: nil, }, }, { name: "error_unknown_type", - method: "my.service.Method", - template: "/v1/test/{id}", setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, err: errors.New("random io error"), + wantInfo: TelemetryErrorInfo{ + ErrorType: "*errors.errorString", + StatusCode: "UNKNOWN", + StatusMessage: "random io error", + }, + }, + { + name: "error_invalid_grpc_code", + setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, + err: status.Error(codes.Code(999), "unknown code"), + wantInfo: TelemetryErrorInfo{ + ErrorType: "UNKNOWN", + StatusCode: "UNKNOWN", + StatusMessage: "unknown code", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := tt.setupCtx() + defer cancel() + + got := ParseTelemetryErrorInfo(ctx, tt.err) + if diff := cmp.Diff(tt.wantInfo, got, cmp.AllowUnexported(TelemetryErrorInfo{})); diff != "" { + t.Errorf("ParseTelemetryErrorInfo() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestRecordMetric(t *testing.T) { + tests := []struct { + name string + method string + template string + err error + wantSum float64 + wantDataAttr map[string]string + nilMetrics bool + }{ + { + name: "nil_metrics", + nilMetrics: true, // Should return early and not panic + }, + { + name: "success", + method: "my.service.Method", + template: "/v1/test/{id}", + err: nil, wantSum: 1.5, wantDataAttr: map[string]string{ "url.domain": "test.domain", "rpc.system.name": "grpc", - "rpc.response.status_code": "UNKNOWN", - "error.type": "*errors.errorString", + "rpc.response.status_code": "OK", "rpc.method": "my.service.Method", "url.template": "/v1/test/{id}", }, }, { - name: "error_invalid_grpc_code", + name: "error_recorded", method: "my.service.Method", template: "/v1/test/{id}", - setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, - err: status.Error(codes.Code(999), "unknown code"), + err: errors.New("random io error"), wantSum: 1.5, wantDataAttr: map[string]string{ "url.domain": "test.domain", "rpc.system.name": "grpc", "rpc.response.status_code": "UNKNOWN", - "error.type": "UNKNOWN", + "error.type": "*errors.errorString", "rpc.method": "my.service.Method", "url.template": "/v1/test/{id}", }, @@ -467,8 +477,7 @@ func TestRecordMetric(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx, cancel := tt.setupCtx() - defer cancel() + ctx := context.Background() if tt.method != "" { ctx = callctx.WithTelemetryContext(ctx, "rpc_method", tt.method) From 41eb6cfd330ae35b13b414aa19169c65ef0fcd12 Mon Sep 17 00:00:00 2001 From: Wes Tarle Date: Mon, 23 Mar 2026 23:10:06 -0400 Subject: [PATCH 3/5] feat: add Message to APIError and extract googleapi messages natively --- v2/apierror/apierror.go | 13 +++++++++++++ v2/telemetry.go | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/v2/apierror/apierror.go b/v2/apierror/apierror.go index 90a40d29..03777faf 100644 --- a/v2/apierror/apierror.go +++ b/v2/apierror/apierror.go @@ -248,6 +248,19 @@ func (a *APIError) Error() string { return strings.TrimSpace(fmt.Sprintf("%s\n%s", msg, a.details)) } +// Message returns the original, unformatted error message from the underlying +// googleapi.Error or gRPC Status, without additional details or context. +// Experimental: This function is experimental and may be modified or removed in future versions, +// regardless of any other documented package stability guarantees. +func (a *APIError) Message() string { + if a.httpErr != nil { + return a.httpErr.Message + } else if a.status != nil { + return a.status.Message() + } + return "" +} + // GRPCStatus extracts the underlying gRPC Status error. // This method is necessary to fulfill the interface // described in https://pkg.go.dev/google.golang.org/grpc/status#FromError. diff --git a/v2/telemetry.go b/v2/telemetry.go index deefc244..93e071fd 100644 --- a/v2/telemetry.go +++ b/v2/telemetry.go @@ -34,6 +34,7 @@ import ( "errors" "fmt" "log/slog" + "strconv" "sync" "time" @@ -403,6 +404,15 @@ func ParseTelemetryErrorInfo(ctx context.Context, err error) TelemetryErrorInfo // If there's an actionable error, the reason takes precedence over our calculated error type. if reason := parsedErr.Reason(); reason != "" { errType = reason + } else if httpCode := parsedErr.HTTPCode(); httpCode > 0 { + errType = strconv.Itoa(httpCode) + } + if message := parsedErr.Message(); message != "" { + msg = message + } else if parsedErr.HTTPCode() > 0 { + // For HTTP errors, avoid returning the raw, unformatted err.Error() (e.g. "googleapi: got HTTP response...") + // if the actual parsed message from the response is empty. + msg = "" } domain = parsedErr.Domain() metadata = parsedErr.Metadata() From 55a8f4d05f7ce9703e22cc7e86f7a8cc4febc77d Mon Sep 17 00:00:00 2001 From: Wes Tarle Date: Tue, 24 Mar 2026 12:00:20 -0400 Subject: [PATCH 4/5] test: add coverage for HTTP message extraction and update docs --- v2/telemetry.go | 21 ++++++++++++--------- v2/telemetry_test.go | 35 ++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/v2/telemetry.go b/v2/telemetry.go index 93e071fd..f6accaa9 100644 --- a/v2/telemetry.go +++ b/v2/telemetry.go @@ -337,6 +337,11 @@ func grpcCodeToStatusString(c codes.Code) string { // for telemetry purposes. type TelemetryErrorInfo struct { // ErrorType is a mapped string for the error type. + // For stability, this maps client-side cancellations, timeouts, and known gRPC + // status codes to standard string literals (e.g., "CLIENT_TIMEOUT", + // "PERMISSION_DENIED"), and falls back to %T for unhandled types. If an + // apierror.APIError is found, it uses its fine-grained Reason() (e.g., + // "SERVICE_DISABLED"). ErrorType string // StatusCode is the string representation of the RPC status code. StatusCode string @@ -352,16 +357,14 @@ type TelemetryErrorInfo struct { _ struct{} } -// ParseTelemetryErrorInfo extracts the error type and status code from an error. -// For stability, it maps client-side cancellations, timeouts, and known gRPC -// status codes to standard string literals (e.g., "CLIENT_TIMEOUT", -// "PERMISSION_DENIED"), and falls back to %T for unhandled types. If an -// apierror.APIError is found, it will use its fine-grained Reason() (e.g., -// "SERVICE_DISABLED"). +// ExtractTelemetryErrorInfo parses an error into a TelemetryErrorInfo struct. +// It relies on standard gRPC status codes, apierror.APIError parsing, and +// context inspection to determine the most accurate error classification and +// provide detailed metadata for telemetry systems. // // Experimental: This function is experimental and may be modified or removed in future versions, // regardless of any other documented package stability guarantees. -func ParseTelemetryErrorInfo(ctx context.Context, err error) TelemetryErrorInfo { +func ExtractTelemetryErrorInfo(ctx context.Context, err error) TelemetryErrorInfo { if err == nil { return TelemetryErrorInfo{ErrorType: "", StatusCode: "OK"} } @@ -410,7 +413,7 @@ func ParseTelemetryErrorInfo(ctx context.Context, err error) TelemetryErrorInfo if message := parsedErr.Message(); message != "" { msg = message } else if parsedErr.HTTPCode() > 0 { - // For HTTP errors, avoid returning the raw, unformatted err.Error() (e.g. "googleapi: got HTTP response...") + // For HTTP errors, avoid returning the raw, unformatted err.Error() (e.g. "googleapi: got HTTP response...") // if the actual parsed message from the response is empty. msg = "" } @@ -441,7 +444,7 @@ func recordMetric(ctx context.Context, settings CallSettings, d time.Duration, e attrs := make([]attribute.KeyValue, 0, len(settings.clientMetrics.attributes())+5) attrs = append(attrs, settings.clientMetrics.attributes()...) - errInfo := ParseTelemetryErrorInfo(ctx, err) + errInfo := ExtractTelemetryErrorInfo(ctx, err) if errInfo.ErrorType != "" { attrs = append(attrs, attribute.String("error.type", errInfo.ErrorType)) } diff --git a/v2/telemetry_test.go b/v2/telemetry_test.go index 421cdffe..6a87604f 100644 --- a/v2/telemetry_test.go +++ b/v2/telemetry_test.go @@ -47,6 +47,7 @@ import ( "go.opentelemetry.io/otel/metric/noop" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" + "google.golang.org/api/googleapi" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -338,17 +339,17 @@ func TestTransportTelemetry(t *testing.T) { } } -func TestParseTelemetryErrorInfo(t *testing.T) { +func TestExtractTelemetryErrorInfo(t *testing.T) { // Helper to construct a real apierror.APIError with an ErrorInfo st := status.New(codes.PermissionDenied, "disabled") stWithDetails, _ := st.WithDetails(&errdetails.ErrorInfo{Reason: "SERVICE_DISABLED", Domain: "googleapis.com"}) apiErr, _ := apierror.FromError(stWithDetails.Err()) tests := []struct { - name string - setupCtx func() (context.Context, context.CancelFunc) - err error - wantInfo TelemetryErrorInfo + name string + setupCtx func() (context.Context, context.CancelFunc) + err error + wantInfo TelemetryErrorInfo }{ { name: "success", @@ -415,6 +416,26 @@ func TestParseTelemetryErrorInfo(t *testing.T) { StatusMessage: "unknown code", }, }, + { + name: "error_http_with_message", + setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, + err: &googleapi.Error{Code: 404, Message: "not found"}, + wantInfo: TelemetryErrorInfo{ + ErrorType: "404", + StatusCode: "UNKNOWN", + StatusMessage: "not found", + }, + }, + { + name: "error_http_without_message", + setupCtx: func() (context.Context, context.CancelFunc) { return context.Background(), func() {} }, + err: &googleapi.Error{Code: 500, Message: ""}, + wantInfo: TelemetryErrorInfo{ + ErrorType: "500", + StatusCode: "UNKNOWN", + StatusMessage: "", + }, + }, } for _, tt := range tests { @@ -422,9 +443,9 @@ func TestParseTelemetryErrorInfo(t *testing.T) { ctx, cancel := tt.setupCtx() defer cancel() - got := ParseTelemetryErrorInfo(ctx, tt.err) + got := ExtractTelemetryErrorInfo(ctx, tt.err) if diff := cmp.Diff(tt.wantInfo, got, cmp.AllowUnexported(TelemetryErrorInfo{})); diff != "" { - t.Errorf("ParseTelemetryErrorInfo() mismatch (-want +got):\n%s", diff) + t.Errorf("ExtractTelemetryErrorInfo() mismatch (-want +got):\n%s", diff) } }) } From 2e346b6d9c67f8a8828dd73c2725c29409049ae7 Mon Sep 17 00:00:00 2001 From: Wes Tarle Date: Tue, 24 Mar 2026 15:35:13 -0400 Subject: [PATCH 5/5] docs: apply PR feedback for TelemetryErrorInfo and APIError --- v2/apierror/apierror.go | 2 -- v2/telemetry.go | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/v2/apierror/apierror.go b/v2/apierror/apierror.go index 03777faf..cba59191 100644 --- a/v2/apierror/apierror.go +++ b/v2/apierror/apierror.go @@ -250,8 +250,6 @@ func (a *APIError) Error() string { // Message returns the original, unformatted error message from the underlying // googleapi.Error or gRPC Status, without additional details or context. -// Experimental: This function is experimental and may be modified or removed in future versions, -// regardless of any other documented package stability guarantees. func (a *APIError) Message() string { if a.httpErr != nil { return a.httpErr.Message diff --git a/v2/telemetry.go b/v2/telemetry.go index f6accaa9..a22ab532 100644 --- a/v2/telemetry.go +++ b/v2/telemetry.go @@ -342,14 +342,19 @@ type TelemetryErrorInfo struct { // "PERMISSION_DENIED"), and falls back to %T for unhandled types. If an // apierror.APIError is found, it uses its fine-grained Reason() (e.g., // "SERVICE_DISABLED"). + // This is used by metrics, tracing, and logging. ErrorType string // StatusCode is the string representation of the RPC status code. + // This is used by metrics, tracing, and logging. StatusCode string // StatusMessage is the raw message from the error. + // This is used for structured logging. StatusMessage string // Domain is the domain of the error, extracted from an ErrorInfo, if available. + // This is used for structured logging. Domain string // Metadata is the metadata of the error, extracted from an ErrorInfo, if available. + // This is used for structured logging. Metadata map[string]string // _ struct{} prevents unkeyed struct literals, ensuring backwards