From 9a65f1ca645dc67dd0007b6d75d7d005549134c1 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Thu, 19 Feb 2026 14:21:10 -0600 Subject: [PATCH 01/17] feat: add initial implementation for Java Observability Plugin --- release-please-config.json | 8 + .../.gradle/8.14.3/checksums/checksums.lock | Bin 0 -> 17 bytes .../8.14.3/checksums/md5-checksums.bin | Bin 0 -> 21997 bytes .../8.14.3/checksums/sha1-checksums.bin | Bin 0 -> 24653 bytes .../executionHistory/executionHistory.bin | Bin 0 -> 134528 bytes .../executionHistory/executionHistory.lock | Bin 0 -> 17 bytes .../.gradle/8.14.3/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/8.14.3/fileHashes/fileHashes.bin | Bin 0 -> 24497 bytes .../.gradle/8.14.3/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .../8.14.3/fileHashes/resourceHashesCache.bin | Bin 0 -> 19789 bytes .../.gradle/8.14.3/gc.properties | 0 .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 + .../buildOutputCleanup/outputFiles.bin | Bin 0 -> 19289 bytes .../.gradle/file-system.probe | Bin 0 -> 8 bytes .../.gradle/vcs-1/gc.properties | 0 .../observability-java/CHANGELOG.md | 1 + sdk/@launchdarkly/observability-java/Makefile | 10 + .../observability-java/README.md | 113 ++++++++ .../observability-java/build.gradle.kts | 15 ++ .../observability-java/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + sdk/@launchdarkly/observability-java/gradlew | 251 +++++++++++++++++ .../observability-java/gradlew.bat | 94 +++++++ .../observability-java/lib/build.gradle.kts | 101 +++++++ .../launchdarkly/observability/LDObserve.java | 137 ++++++++++ .../observability/ObservabilityOptions.java | 112 ++++++++ .../observability/ObservabilityPlugin.java | 101 +++++++ .../observability/internal/Constants.java | 21 ++ .../observability/internal/GraphQLClient.java | 115 ++++++++ .../observability/internal/OtelManager.java | 255 ++++++++++++++++++ .../internal/SamplingApiService.java | 135 ++++++++++ .../internal/sampling/CustomSampler.java | 196 ++++++++++++++ .../internal/sampling/SamplingConfig.java | 140 ++++++++++ .../sampling/SamplingLogProcessor.java | 54 ++++ .../sampling/SamplingTraceExporter.java | 51 ++++ .../graphql/GetSamplingConfigQuery.graphql | 53 ++++ .../observability/LDObserveTest.java | 74 +++++ .../ObservabilityPluginTest.java | 66 +++++ .../internal/sampling/CustomSamplerTest.java | 178 ++++++++++++ .../observability-java/settings.gradle.kts | 2 + 42 files changed, 2295 insertions(+) create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/checksums.lock create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/md5-checksums.bin create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/sha1-checksums.bin create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/executionHistory/executionHistory.bin create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/executionHistory/executionHistory.lock create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileChanges/last-build.bin create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/fileHashes.bin create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/fileHashes.lock create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/resourceHashesCache.bin create mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/gc.properties create mode 100644 sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/cache.properties create mode 100644 sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/outputFiles.bin create mode 100644 sdk/@launchdarkly/observability-java/.gradle/file-system.probe create mode 100644 sdk/@launchdarkly/observability-java/.gradle/vcs-1/gc.properties create mode 100644 sdk/@launchdarkly/observability-java/CHANGELOG.md create mode 100644 sdk/@launchdarkly/observability-java/Makefile create mode 100644 sdk/@launchdarkly/observability-java/README.md create mode 100644 sdk/@launchdarkly/observability-java/build.gradle.kts create mode 100644 sdk/@launchdarkly/observability-java/gradle.properties create mode 100644 sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.jar create mode 100644 sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.properties create mode 100755 sdk/@launchdarkly/observability-java/gradlew create mode 100644 sdk/@launchdarkly/observability-java/gradlew.bat create mode 100644 sdk/@launchdarkly/observability-java/lib/build.gradle.kts create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingLogProcessor.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/resources/graphql/GetSamplingConfigQuery.graphql create mode 100644 sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/LDObserveTest.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java create mode 100644 sdk/@launchdarkly/observability-java/settings.gradle.kts diff --git a/release-please-config.json b/release-please-config.json index a4432ea213..4a86ce9222 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -22,6 +22,14 @@ "include-component-in-tag": true, "extra-files": ["gradle.properties"] }, + "sdk/@launchdarkly/observability-java": { + "package-name": "launchdarkly-observability-java", + "release-type": "simple", + "versioning": "default", + "include-v-in-tag": false, + "include-component-in-tag": true, + "extra-files": ["gradle.properties"] + }, "sdk/flutter/packages/observability": { "package-name": "launchdarkly_flutter_observability", "release-type": "dart", diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/checksums.lock b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..ffa4434ddab8c32ebdf9316abb1a63641f56e78c GIT binary patch literal 17 UcmZR6P#&GD{_dR%0|cA_060noHUIzs literal 0 HcmV?d00001 diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/md5-checksums.bin b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/md5-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..b1e88861fc992b49558948fba5de28cff19b1c39 GIT binary patch literal 21997 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 literal 0 HcmV?d00001 diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/sha1-checksums.bin b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/sha1-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..3614388bc023dfdd88b34b06af70223e68074b37 GIT binary patch literal 24653 zcmeI3c{Eks|HrTKnuidgq(UVkswbo}UbD=zWC|gKhzuE%IVGf&Xb@4U=Ud8=~I!B8@5aIg`8{ywg z=-(glPj~`&0(b&=0(b&=0(b&=0(b&=0(b&=0(b&=0(b&=0(b&=0(b&=0(b&=0{>SM zutW}o100OV21fJ)ZzzGFz=eDWjQy_UUUe(t2>5Fg2JHV2;;#5fn`J7M0q)d^@p#W* zkF4!oGJxBB#(0AM9l6dGz= zA9BbDaEIF%&v({dGqv1_A8=D`D*sYvc(mwR9Na$!<3+Y!#gC_i^8q)xfbk2HTW*|f zKVk~Fr3A(=>g>Js+CthGaBCJSf50cr*S=r~xbszvm&_1&#tJt60XS(F#>?iO)HMWN z@B-YHjPYyy(wDxa42}WrM*o^V=^7Hv>Y|3?j8&+Vv(nWjZqSd;? zfLrim{7){o;5A!0^#QkQr}Ag-qtiZmB>`@K8RK`)3@lqUUC{`?pBv*fZ{BR09-fu} z+;KI=>)NDm2ufb^1l%|W;|&)A&b$-b;tjYyhJUvR<}G9!yTjwMh{XP)Ww-K*rgAHDi`lxX4uCrcW4v|m!W84rm*#+5=wiH0z`2^uuICNl zPKp?BpN^B4ReTr#xSc!3J9T&u*6eMw0Nnf{l{?1`ddlve1l%#5%AakOnlU|12HZvf z<2|hkBvRs@9>9&AG5%V7=P) z@MOtoshQkr>9upX@N(SiI^6;Ffh% z-hO24=#>5!z)7JP|IS#<>AYEfIp7WkR4%eJGqT`9BH(uU82>T%BR=x!if+KoUt@e) zIrc+`xCRs8mX;Wwk+1r+T~-*GPtj&|n#w%_lIssNUjSS`8{>1ASFd_i&npSIDKEx< zE#v$!uF?(9qh3757aWuCC~35P0Q(ziVw|AFvPGM8M-*_qyHsA|en@rt3<+?n=NM<; ze_0W$$2bJINe`88U+uv@!K?tdX(h%P0~KqY9}rIg+(r-MOpaa(gtA(AzVx3^dBEDs z=@pVNXM-Y)GYeQ-zX;@Sg5PtC%D+sUVp-KeS&ayU-ODk~8X^{V=};70FC4y7d2_IV za@NaD;Crl9G0q+{?ICF~2d{UVD2#KLv>Qgg$c5{aQ6k29?%iyw$+SEJzQ^nt#swqN zzjCo?CIjyL4&y?mBKFxEL)rj0@x}O3)>85Cn}ZtxH)q25vPUYeX`iFfKFyFh9%PKMZjD?HFHcZ+r56_~~K5EmJTqCsGg+ z&=hkHaJ>nPE9ksc{hqO<1MuC+82@8@%jo2%F(tsA&R|@{L2$D2-bW35v4LG;JR-)gK^7!EL}E=Efrw@-4Cez`1&nf zhg4wByMJKZ%30pq;FEn8*xzV3mA`MyCt7s;3BSi2Vcdn=#oVdx zI=sJfoX5DEYpdI6PzgL<(lv~Gy(@cK*R^^ae2<<9#(g56gHIW)fV>yOwXlng4Yn{wvi~h`n ztil*hjui@z^By&HoB*|$>?oxO9Q)~O{WEK(qWhjsZ?x^#%9rE2U0d0G3k^j;EoShf z{-$0ceJ#r{-)ak0^#U^sDf?ULBPTFmIPg)~;zyv)Dk zPDn|YJ_~*)%VOsd(Q=;cuNM0cdaFTWN9z7iuGx3amiBwKpNMG~yo&#;#rmDz8Wzjx zIhCT?ple+F{KPBq+hyYp$W#B6G_d1BpSGpxVZTG4n(^(34Tsh|8`x>t=PTg3rzyc>)xz)u&342>iVCR_ju$w%re_EEc44 ze(;Z&GYGF=$^b1jU8%@bs*Ozr@$w6X!oJfDzrHlTGeyQ9#nJ_q zdl9`A7~bs`Kdv>%=hA2Vaa%K6arIZ^Dx_HOIbA{YJqVf%|5+^&@8ly_LZmNmSg>&Z zXmFIwp2>_{jg(r6pogsJv_w-&#Lc^XP^Z~6mgNW zJhUybV_&zx1CzSnX_MUteoy8LK&v0lqP!RAYkehe7b<>n{`6~|#97~;(QY$qjga$% zrqQngddT;a-iqf_kiKGieQ2!WTRO>4{aN9Ienn`(_o?^_=q)Ax(5sUkDSS_dt_t{_ zYm!O|Mkg6ctwK;sN$bC5c~$J^Q@8!|>d3dB<+|SGy6MA)Rg_xfa^#9Z8xei;f+qQx zcVW5Y)4taELcdHcQq;Ty!VkQLmOMow@II%vGOOy(9CIPvdOwuSOXMc)d_RYYeWQn_)~c&LUz3$r zC`*X$E1QUk>OMS)K2}btbrE$4jI1HZmc|;sK0d-1HP3u2TR+&kc?7K^Xa|w&>mjnC>7iYR(Y^H4&GqLB%S{Uf zGb@HqXzYL%e6NNdRcWjNztKNeo8^6Cv+y71R!dEFi+>N(=tHkYq;Z`_HZ;~hOGCf7 zNqS0$L3WpZ(JB#B!g6=%_5|2Nq6_z%A_w|6) zFHVsNoW1mxAJIml<#n)_K;6Nb`C`TteJMLmXzc_RR}#G?avHE^;5$nzOI<(WaRvXRCY#9(Fh72rHjOW-^8pw-a&j?KPG~ z*2=%Jka5_b1A4fa#d1vB?cV;^(RbeU5KAQd>|gQvA#)Nsdi%i)K;Vw2Kdx=kdWOp~ z4+Rk3Uz004xTRUkARf8$Db@o}i~Bmg)h;xtnc6&dk5pP_^j3X|tIw2(6Xw`uHo$&7dBKN89;gS&R<< zB-eg`RyxIE^iiO<&MUrsrPUEA##>bN2fMk;JDW?s1#6nKGJe**lrCDLtHv*8vtMI!0)c7k<~`{-pU^W!#%kFALZp_J|JSnK@ArHi%V2d36S3%Peg8{ZyeLsQF7D$Q_q|3$Xz?n{+OTomG}n(6qGoirAUzog2(BtG=n|i`nL*Z zIpjUxbuhf@3fZST4lPSyEw+BuM?N~=K6sbh`SO@#)!WjOJj<)GTIf9u(gezo4NVW% z#4=`GBA>P4N;*{;xu>VY+pE(H)`Fv#xX273D%0ea`Xod8@lXM`ic55Ok(NC&?vSf; zE}@1}iokD6U(3}Z#4wtzE3`v+b8&{xxy^IjmR8Vu3oN1ty~UYaby(Zum7}KMt8Sq( zYiDkDVl=Q4Ve7CDn^%Eh;dnq7fe_8HEe(udgUXH6%Gr^c4)CS{q24kS_Wt(0^#T)v(1*A zi`%yv$cEo3x)IFo9JHs`V;)(D(F_yOc@SwtL}i)E3y*Q@p_$FHq~7CJW}ZLu`wBDWJ;qAiZd2|XZf>y(agjd0tD zSjg%SO$3&J2J(|;ME#A0oaH3VzqdqH_}R$6nOTlDe=Hik(xBLT_+~xqVIZ(Pk$IoC zmcK}iK#qR5oVB_a-`D=|bM1*Ay|0$iJlseWeYR}RbymwO6_%k20Dv`aB z#)RIx(HvLr++=-}Ol{W6*1;tMe3_AXj;C6o1y&*^|6qFSSyzT@xu2At(Xu?YsmoU# z-``ekhE_FNiNGw1tTD8;Hfl?CohOVQ5lhr6*w9lWFLit58MMG%B^Q?%y*0evh*04Y zYIU|da;oX!-8JIg#YhdxS#Cf}5tuw9=q)RPRi@JInz47i>sTatyZI&hfA z-0~9oJ@6&_HncuctYy6DPZ}#OW@Tb^!ozLLeL7Dw>uktf8ZYQU=juList@M^%T0j~zU8t`hss{yYDyc+Ooz^ehT z2D}>ZYQU?3!mNQVfcY;d0V;pXA-Fstyllu|A0r1aqoAw{v-SoHc z@3!qPP|w@?(a*(;0(opu6@R1B$1kFvcW5d3X9be@oJx;& zq@VYF8PP@crt@(s{kv`SbG-Mt@u|a-Rn+s%59#No;*BkTY0A~5=)4#}Kd1kK=O?H< z-v7NC@M^%T0j~zU8t`hss{yYDyc+Ooz^ehT2D}>ZYQU=juList@M^%T0j~zU8t`hs zs{yYDyc+Ooz^ehT2D}>ZYQU=juList@M^%T0j~zU8t`hss{yYDyc+Ooz^ehT2D}>h zcWXclLEp{-pGn{|akfU&FVvcXZDc#B| zy~?k>&gBWT)?kX@MVL@bhx1UQmS;iDQD&an5N*_gC+*N!)Svp^CjeI)OsECNkQ=JQ zJ-)Ft7bFZ0Uf!eo;E!;wP^mtlpQAKWIcSFyXZ>QMc@~2~r;bFmdY)Eq!A*Kp#|ywU zs8wePr{6fSYjWXonlpJ`G;TpL)Pk0SR(URmpldG#5lPF;J3lY==~8_W{*;z`^^4sS z=IKyctrl}RXuap68N!qKTm)I=sUV&<+Ni^$alHk#09|mCVsSlY zF!`E|xLT{xs#kj?@cFg5)FY3SP~35-KE4+9eLRuJ)&NS(cG5M`)!I4Ujq4iNIV`9{ zXdjVqT(kkR>QIx`5?>D5=#<6Y>WR)^YY!pzv|&J#PF5RLO=qYNg4 zP8-Fy%|LuLs*c3Xe6q3PW?vCs17;!cmnY<_4bfmowd&Q87;1{r#q*ti_!12JnsE~t z|9s6@6kpC0ig*IPQmzsy6iR_qp;2j2kx(jxk8dDF<*)p zuODjSA`d!04M@G$X!1WH^~bkTH=4ounAfuS8Z5YuFW?dKpkkp$D3wc;VueJ4VH%}K zC6r6mB9%yji)AV$E|ZH;sXB9U2ztIGJxZi=3wKsh{I%l7%!C#7Vn?^T!MT|Yk+cS$ z!3f58&^o}>YKrGGevx$|Lm@TDkFTvu0)x)qn+>wvC&P?K$8Kl^cBC6K73a#SqFae-7Uz%)|123Kk{B7uy>KZ6!Sk{W+@)y~+J*l4S9POr4EFDr8X z%6&;dpYYWgBFwZ9VzEXk6Ja8yN+rWVLlTPx0;x(UQ>p|?i9(IZK`&8=Ft!jh83zB? zpH=)>_Il2us0bLtM)ncVsA`#*mjYh0d zNhAUVDiMq1DpoxZBG@P~q`1RLZ2B^>-l?>!>C2v%JUxi>C~u^&m{2uNSA$4|sxT#v zDFEw29DFoFrA&dUHA00@uEC^ol|}?=M&L#Uk`6-xfA!UF2WpqRb#+&9-Kie)_i&!% zjRasMjyC9N5mZuvSRoV1qzW162~yC1QJDzThg>WGgRfeRO2KOs#oUNM5n{;DCj4>F z6cuFH`L5KRw#}FNR=5VZBQu+FL9-W|KFNznCsl{OdlMG#VNA1ZwxSLD><_p6)q zecU*_5G;X^Gx7b~^=Xj0Vdch?Wvbsd-YldGAY|v1jpH{Ld)+Yki_l*;Prcw%sFpv- z1o40hA>&unoSb_9NJ*vHTnHCG$eR@(LYgE@KC$I;7}BQtj%S5z>4VI?ZvFjD?=cl4 z(uZD0E+!l-bPFG(cm@9f2Wp513iNBN6}vuKmbU3;YEqM^$QS+YJk-fSfo-tv=gmLP!g>k`yM7+5% zG;Mo}(PI}?dN&TASPltMcSwDc_+x7C`ZvG!TXb=#-B#sTd37`y)VSGfFcF4HXf|zg zQW*>uGuZGLNi!8;LV_mK?~DO7iZBzQl);Mmi?mV@ z)}xlrwk1{?!qUXoX;pl)NzIQ2rZYbI1gtL^Ru^V2K5e|^`vXI(1=U;3U{M&0a*?Qt zeXqg0CcUpx?o<)K!(~>&soA4+m%Sz~2*S1jRTkMP*k7jwmLZ$K+sA#kJY?X($Q>qV z#g*ve{_vKp3hYYlc0m-n1F(UGpnBl>G1)|3TzR)&_o>o(ZDV$iyjX0G4&Ibik=e8! z7es;0C~)ooYdYWGYPJ}n!-$`D*&(dQn6NJ+I&td-R2|SvH4okkXI1!2=(~6axCXMjK&k1pM_+q_^rnoN-2;{&SPl?{~rbK3x%R2)iu$3#2B34H5RC-KLM= z0nzn_{Gm7#e`xWt>;ltV2hYR_a1-hTm)fi5g0t0O}W}8~RzS2VZj#uVU*s>`v zPTy`&1wN8}wYbuMP!RP(tHaNz4FglNR*l9tKJ#fr2y_Qi9pmSRE+msI5+) zN3~w>v;So8$EQo4TF0B7;&Cp_kk+jNRF@1UJL;?vAWDT=-@$vFUy#v%TK?|KgY zZQtMUMh~@`5h?fq%yOFM;Yg3XderjSmqn81Jed{?b>sG$jD+|2yIsFar8e&W_D@5z z_B*Uaree6+qSce}9KdOgt1SjoJoG$onN(S|I*d;`l7X9;TzC^paA-JCDm1}))ea>X zoEQ4g7-?oVMikW1^ef zt2)ZO;jDO8x0LFWSCh%dh~P=i%QahKq@}K<<$A(`>a|n8Osw;`@rt*90ov1Jp)qFL zvI+i$v2OCAhpx`J8N@d6Y;;x(ow(>t`_wMUg008QHD?r0Cky=mcx2?JlZzUiJTc?x zzME@iE~``Y+nW1Nk;zAgz`xSusl|zpp9MJ8R8|Wff?Cv(wiS!5%pxJRg|%Z&v=~w3 zm-`K4HuQUao6yV7lJF815itKUpU`Zaeb{C6{_%;1jcw*8jXG9x?OT$ThP1$zRRlI7VJK{ z=O-%b&Y~{ThZ#|QAPCj5xG6tWf^jP+bZx(G*R8qb_diLaq@Pt5Udk$;Ai{h^^D(8> zrtK@%E7Q&G`oh!7BNg6|BCM+5B0^VGXQgU@&brC}$G=uBOMG!wI`j*;H{9wanRRPX zc&huNPN2_4L#kf7e00mpM%QX@|7kc`;X5nAv+4AVMUt(CpZm|PZ0!;F?A(BJPb+ZS z-d|7VnqL8)L+4_yA#B-NY(@SIFS)t?%6;!Dy_tQo^f@xyj0#)`>9J&&P&Z%y$AOX`dK~xEV5?}7s9_VhEUt|5I>5HXmK%R} zCAIjB;n!|<*Uh-Qs{4aEWbVUX!n57wrgOz5kFHaxX<~zUMc=(2dTUExD%ZTy@Ek@i zY6kET(%IH7sVA_0EPr`uJn!ngqOD%7CJRd}4$pFvjm|VrI;Kx}%~n`NrC(*m+)^Xg zkeTi^1lnm`yFd5x6ZGZZeY^hphfv!BJW3hSXQSX@>h^%tvnxjZ61*HdTGzvL?AyAL z3sNUQt(P|2+Z;TK=oF?N`F_%}nCp>`>b){eDzbz&qeS3(oQ&?ItS23;77!rl7}rnb z?PN=h>vkUalu^gbcViiHjQT*H4rg|p?Jk~NZbS+3YH8ra2}P1=c_MKX)HYj;w9g|3 z#f;O0<&O0kMiU;ZHCWC5WWX&ov4RLy_(Xm%ZHYD#Ay!1xZ#%-mMKj||JtVpDB?4x7 zDlp$7+ma6Z^b}Q*zvo5=U3qnH>^8D-4!Z*vfk9Am@;nftTx28otRgP`CO~cg@$2rtOLBbD*Tl6KIoDNFW^s$|EC!$yjCE z#u7}Il-)0r&^kB=wAc}q#b=s;Rgwt1B>FjshzlbE2rb%J9G>FzhFB#L#!T*zS)jwK)t}JS})ce2FDa3IJAo9uXAB3vfv{Iw;gXxJy9b zY$s|&QsH8KZ;Y(Y9~r^0see%mTj+ZlPy3YSq-qiksqzKt_T?F0$xy6WQb2 z{EH8^#z09v?ZV+DwpSiI` zVwIAXxo{$Lu@61XTOO(Dz)|hw8lSFmcwXkJhg?+ubkiST-Fl=b7a`j$q=6<60;Vm@kt2ydRKa{aohX?I z&20?^qi}6B$PTtXW_X$j2az|JRjmbK&BVJ7CNO1l{*&XXW6T!0OefRqLoV^oKB0^W z2@Lo14GL}T7_iCr%&8@Xnb~F1_|(Z|E`uI>dT~r-1S{aJy9tJw$YqLg)W0ahc~#^_ z4UYL0yk#oEsYD)@V-+~n6*(2VpI=28iSJZh;3x)bejPbLYm`YiT&HQa`s@4{Y{_kr zvrUCq4Z<*CSlOSqb8H4S9in`OM-_%AvLBr;7@RSEy)ZnF%_lR#m4e8%-9rt-OEYKR zp7LRVmcz-@PPT2Xi%gzY29PDFwm=fm+r(u%@t9mS&~gp05pA@HS7Z%kP( z?r7oEUj{xPF2eQ!!RK?VybQ`pE1a5h@jY=7w(ko*nPZ)~l(N5YYK!gd1Il7~``{Bf zRu;RELOP~p?B3)eZ1-j^jAwTwKwT!ih}g)ii|&1FGZmo^t+yR>tm zpRx;iTS(Sp%*6n`AY=RR;r%&nR21c1g{=3vMk;U-cBDd1;v!w??=EB-aub!oMc7dp z@X_oyA2$W$ainohcZYPBVuzV<5q6jfyerFk$%tqk2U})S7pxE&F2W9xfq(a45@1ot z<1p%MiphwXaU0&lMcCmz@NQ3a?wM-Aw0gPYo1ReE;YuE@UKtb1;}aLT(G-|bS6qZ0 zb>;D-5kq<=HJ1~yxf^_&d*aho&yF7Zv^Z;92s3Frev=>WC#J98HO?cC1NSNYyJx~f zyhipTJvg5ySQ=pm0e*&H4ws+{myi>O15^Y}aP~RFFY}{465((F?~5Pe7aG($Ff5!G z(wd7boVC2EUxnFiwp@A5U-s;~%J6un*Po3q7q(n<36Doj>PQfoMg&>FFY-fw;DD@| zI3){k#%w4w5b`?d+{nP`k_r_b{jd4_HkupH58Y2q6Q?9`h=`lI`hO|Z&c6xk?h<=@ zodw)JBG{;(Rj&o-f2~F>hzH@5zkKOmQl3!Elkk;dsYI?4<7xo71kiRgGO0!`ma2sc zr2+#_0W1~)k0ntubNf=c!C4ys&9^l0xG}xq?u(m8$Zqs~v9KEF&)jF%*NB?XXfxl& zwHg3-32+b=rq-x%OrR1fg>r!y1<*_yfDa^>qH?JU6UtdkQ#2U73l`ozlD;^$o@%;k z#l*h@l^mDTO*vP!FNhtb85a@wVsf!eDivw~rjbOck}H)WiCiefH8PDp$+9>3EC7$@i{JpCHK=Xquyi7o%@#>N8{8cERmc+qFfRc9iK9}PN{oVr4H`Zs(O@z; z=(+-2BLb*zY66E6m*Gx1K}!KSsfJSYOw`*K9h+a8{LA-}FC-hvact+5e zelk=hR)J{pMYYlydP5WMSR%8F#$M^m-=; zuUj4cqWbyG<{?ke5`8->?+P^v4DKiKA}&sgPdoYqdlF70~0TaTA}YUx|`P^NXU&hvQe zCo^*S0YPPN>d2y{mhQ?9_YK@-UrU1@_gY`-0;;{O(x^QwvRbk6GS03D;Op$eCLgJg^x7FeQ%po(5tqP8`f8hQ& zME`KW>vK;zl0EC*o&t}Wwf{mZe*5#>AA9g_=;ftbf=A-@6=J?G9W=jtg$b>%G%vXX zJeoD-+4eez_xbnW4D4PasN>It79WPF5+F}u#D~d<^^EXgIA`*9G@@c7obX}7fo`*a z5mN+QLFgAi;3%Yl&ko`Q+qeWt9LV#CusKc_$`yeWdrY z%8ai5{r6v1*`Tn&9Gp*hP@-pA5w??CD$D3{mZuf9s|DZ;=8~c{F}f4dW}lhz2xNBrN1YX z=u<2Ho4sBz0LL{AHhhp~YPI6b-TkG_tNd#$z9oC$1p{!8ip)jA1vQ)b%MQnwtF_$A zQ|;ZG3kPR224ViUfdO>i{v>MY;+-8&)tz-GZf~odUN8Xqh?xzB!sVDgIK_42YV@@g z2ll*tGJSJ{%_5E$41m5`#fN=PpNp*ix%K9y9oo#e6Px~UpegcIE*vsu7xFIz1JLk$ z^}-|{G*gGXOqT@J3&@2r?LPzt@NCJ_e=6;1cg254k=0`qjdfly06uF}W1rLKBB~{R z+hz*t8?LU{QF3zXf^oUfPuYdM%@4aMxqM>dCNgjfGeRk|yXcs@q7U%>aL0eruWJvb zSUY?(a0aKAzZVR^U<2>xA|n@kvtmck(J9|O^SvlNF{76k48U=Xlx;o#rp$ixvULq# z{W+s__p^Qjs(Zlzbg0?PMPjVTsC!My�`PQeEHUi(~&CFo1o_dcD0Vg^p}Ev1I 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 literal 0 HcmV?d00001 diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/resourceHashesCache.bin b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000000000000000000000000000000000000..155b119acb7ddf9cabf1b5c6f7b435b0872669d7 GIT binary patch literal 19789 zcmeI3X;2eq7=YKS1tE%}H5L#8a!BMDD+C%XB2g1SxjZ-wh#=5n)Id>D3Z@opv>pW_ zK_dbxK>-C7sW=>RDVL~3MG+JgJdlcm3QA*3X7|%6A2a>md3PqsH_txvCi`siE4v(m zplkh2ZN%qo>gOe_pa2wr0#E=7KmjNK1)u;FfC5ke3P1rU00p1`6o3Ly017~X|4V@t zLkBXRJQ(`y(W=G7Ed*h$IkYelTTkJC7rcy1?&jS^{~sLe4b>aSa}bi;r4aFa{q6Z_ zQ%eOTcNHOiHjgEdpXgam%_&0s4~}B$yKh(Sq~=(B;zv^GX~DIPlu!G_Ul$h~ymP;t z{OP1@-dXiG|f|?^lyeuj9r%`KMT&Ou#h?gG^+!P&oicQUtBYur_r#*f2MF)~Q z>mlCS8SQ3KySsJ8%v9UkkV5%^h^wtk4&cwpp+1l6F~rBy zj5X>wmx4*38;>}xQJa{kJM9R`UG5-meBYFLK^(V+}U!!#(kMcbbXN7WX!kCf-l5-sqXS4SRCo=4n zBzJj_xVvXol7{U2Zfd>`;y#O(bzN8d5KsATi2H|@r#<$WPGW)=iFkn1+e2m?69wfb zA-*n~{<=P*^)@v>0PzrYQ4GUSb3e(QpCi8hgJ#!}NvVfO&aFj!;}XZX8#)&H6z@iS z^WnRLmMk3)YR(nJqw|%9_h(LtA-U^Q#L+83jL9JP!7~(q0#E=7KmjNK1)u;FfC5ke z3P1rU00p1`6o3Ly017|>C;$bZ02F`%Pyh-*0Vn_kpa2wr0#E=7KmjNK1)u;FfC5ke zFYwI}Qe8PcN%hbQ|7y*HQI`{y(skzaYGp)_I~BLY18lg0)u#A7y~yZ|C|>g(|8fzzu(?SBxeNTM)t~I-zpmyJ8)`xhYxP_i^3Vt{EBwI zWQ8>NA1kTB4gRiu74n|iNt{TnAe5V;%k8M>~`42lU%dPd< zPPX;JNSy^$mTRj%8!g73uc1OG5LGIPc{@#>g)8aX*J79*r zOF`V?;%v7i($39IJuwks1Go`l#pegl{TLz<^*7Ds8FO&sjXb+=TBUQFQ`y)8v7Pw{ z-}SX}$C6ZQla1}UW$H;qnS1aVew|KoGhgWCF^eR9e)iss!#FKSbtAEq6A z7R}pit-9A}*m>zN^3s%C9OKzDVX}=&`bQ>i{Pzq+2u)v{(HG^>7B@&|98be%ybWJm TIJ4%a|0{)h^LfjZbd~WB#11!6 literal 0 HcmV?d00001 diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/gc.properties b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/gc.properties new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000000000000000000000000000000000000..8f330629839b9d1c850fdc6e26753aa8d3a708a7 GIT binary patch literal 17 UcmZS9R}8Z|v}nsb1_)>X04gB_9smFU literal 0 HcmV?d00001 diff --git a/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/cache.properties b/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000000..374bc9389a --- /dev/null +++ b/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Thu Feb 19 11:31:22 CST 2026 +gradle.version=8.14.3 diff --git a/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/outputFiles.bin b/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000000000000000000000000000000000000..7772875c9ea99054ea382660763c789ffcf648eb GIT binary patch literal 19289 zcmeI%X-E`d9LModQ}e#d6;jNjC~4DF)HE>D_DCd3Gf|_+GKvlpbu&yQBc&`0OsqD@ ziv+_?%>$LRQpmh8GNnT)e4$~booZ{(^H*Zugd{}$24;8o%rpO;`Lg?J*9n5P=_KET znayhE5JxD00w{n2D1ZVefC4Ch0w{n2D1ZVefC4Ch0w{n2D1ZY0N`X+-gXpt|v3Aj% zIxtBTgq4P>1DEuZ`-W3AKJwGDH2VJ_%d*6%*)wD@yG-_c+T*NNE1g~+?iJ)Yb_ue+@-Z=UNk4h+x$J~OFHOGSMB1@fwwmD@&bBkpq-kvH`;Om-e`P%@WAk+)=ao-pWb{>EIsp8QFwyT7)7 zS}Ai$8F`0Ccb@m>Gx2=RdGb#EoJe_VNey#p26=x^Y-Fu%Jb4KDP)v|=%bo6VKK~;5 zaF>N3whyZ0UPu1ra*fsak;oA~fBUre#hX70PT)PIFUU1~w&n-y^|E8@WpZ-Ewv?Ld z+M)sOR^%4Z3db>P9}Dh9M`LX6Dkzg0=V@j08w#Z?i`BN^s7%w01BW03ZMWApa2S>01BW03ZMWA zpa2S>01BW03ZMWApa2S>01BW03ZMWApa2S>01BW03ZMWA{0#+Gscu3~wQKNQF#biR p`2P^qgU->D&TjJDU8O#GcB1~>_? **Early Access** — This plugin is in early access preview. APIs may change before 1.0. + +## Requirements + +- Java 11+ +- LaunchDarkly Java Server SDK 7.12.0+ + +## Installation + +### Gradle + +```kotlin +dependencies { + implementation("com.launchdarkly:launchdarkly-observability-java:0.1.0") + implementation("com.launchdarkly:launchdarkly-java-server-sdk:7.12.0") +} +``` + +### Maven + +```xml + + com.launchdarkly + launchdarkly-observability-java + 0.1.0 + +``` + +## Usage + +```java +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; + +import java.util.List; + +LDConfig config = new LDConfig.Builder() + .plugins(Components.plugins().setPlugins(List.of( + new ObservabilityPlugin( + new ObservabilityOptions.Builder() + .serviceName("my-service") + .serviceVersion("1.0.0") + .environment("production") + .build() + ) + ))) + .build(); + +LDClient client = new LDClient("YOUR_SDK_KEY", config); +``` + +The plugin automatically: + +1. Sets up OpenTelemetry TracerProvider, LoggerProvider, and MeterProvider with OTLP HTTP exporters pointed at the LaunchDarkly observability backend. +2. Registers a `TracingHook` that adds span events for each flag evaluation, enabling guarded rollouts and observability. +3. Fetches sampling configuration from the backend to control telemetry volume. + +## Manual Instrumentation + +Use `LDObserve` for custom telemetry: + +```java +import com.launchdarkly.observability.LDObserve; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; + +// Record metrics +LDObserve.recordCount("requests.total", 1, Attributes.empty()); +LDObserve.recordHistogram("request.duration_ms", 42.5, Attributes.empty()); +LDObserve.recordMetric("queue.depth", 12.0, Attributes.empty()); + +// Record logs +LDObserve.recordLog("User logged in", Severity.INFO, Attributes.empty()); + +// Record errors +LDObserve.recordError(exception, Attributes.empty()); + +// Create spans +Span span = LDObserve.startSpan("my-operation", Attributes.empty()); +try { + // ... your code ... +} finally { + span.end(); +} + +// Flush / shutdown +LDObserve.flush(); +LDObserve.shutdown(); +``` + +## Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| `serviceName` | `""` | Service name for resource attributes | +| `serviceVersion` | `""` | Service version for resource attributes | +| `environment` | `""` | Deployment environment name | +| `otlpEndpoint` | LaunchDarkly endpoint | Custom OTLP endpoint | +| `backendUrl` | LaunchDarkly backend | Custom backend URL for sampling config | +| `debug` | `false` | Enable debug logging | +| `manualStart` | `false` | Delay OTLP provider startup | + +## License + +Apache-2.0 diff --git a/sdk/@launchdarkly/observability-java/build.gradle.kts b/sdk/@launchdarkly/observability-java/build.gradle.kts new file mode 100644 index 0000000000..748460c0af --- /dev/null +++ b/sdk/@launchdarkly/observability-java/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("io.github.gradle-nexus.publish-plugin").version("2.0.0").apply(true) +} + +// Must be specified in root project for the gradle nexus publish plugin. +group = "com.launchdarkly" + +nexusPublishing { + this.repositories { + sonatype { + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + } + } +} diff --git a/sdk/@launchdarkly/observability-java/gradle.properties b/sdk/@launchdarkly/observability-java/gradle.properties new file mode 100644 index 0000000000..248acf1e6b --- /dev/null +++ b/sdk/@launchdarkly/observability-java/gradle.properties @@ -0,0 +1,3 @@ +#x-release-please-start-version +version=0.1.0 +#x-release-please-end diff --git a/sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.jar b/sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/sdk/@launchdarkly/observability-java/gradlew.bat b/sdk/@launchdarkly/observability-java/gradlew.bat new file mode 100644 index 0000000000..db3a6ac207 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sdk/@launchdarkly/observability-java/lib/build.gradle.kts b/sdk/@launchdarkly/observability-java/lib/build.gradle.kts new file mode 100644 index 0000000000..f26bec01ba --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + `java-library` + `maven-publish` + signing +} + +val releaseVersion = version.toString() + +repositories { + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + withJavadocJar() + withSourcesJar() +} + +dependencies { + // LaunchDarkly SDK - compileOnly so users provide their own version + compileOnly("com.launchdarkly:launchdarkly-java-server-sdk:7.12.0") + + // LaunchDarkly OTel tracing hook + implementation("com.launchdarkly:launchdarkly-java-server-sdk-otel:0.2.0") + + // OpenTelemetry + implementation("io.opentelemetry:opentelemetry-api:1.51.0") + implementation("io.opentelemetry:opentelemetry-sdk:1.51.0") + implementation("io.opentelemetry:opentelemetry-sdk-logs:1.51.0") + implementation("io.opentelemetry:opentelemetry-sdk-metrics:1.51.0") + implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.51.0") + + // JSON parsing + implementation("com.google.code.gson:gson:2.11.0") + + // Testing + testImplementation("com.launchdarkly:launchdarkly-java-server-sdk:7.12.0") + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("io.opentelemetry:opentelemetry-sdk-testing:1.51.0") +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.withType { + (options as StandardJavadocDocletOptions).addStringOption("Xdoclint:none", "-quiet") +} + +publishing { + publications { + create("release") { + groupId = "com.launchdarkly" + artifactId = "launchdarkly-observability-java" + version = releaseVersion + + from(components["java"]) + + pom { + name.set("LaunchDarkly Observability Java SDK") + description.set( + "Official LaunchDarkly Observability Java SDK for use with the LaunchDarkly Java Server SDK." + ) + url.set("https://github.com/launchdarkly/observability-sdk/") + organization { + name.set("LaunchDarkly") + url.set("https://launchdarkly.com/") + } + developers { + developer { + id.set("sdks") + name.set("LaunchDarkly SDK Team") + email.set("sdks@launchdarkly.com") + } + } + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + scm { + connection.set( + "scm:git:https://github.com/launchdarkly/observability-sdk.git" + ) + developerConnection.set( + "scm:git:ssh:github.com/launchdarkly/observability-sdk.git" + ) + url.set("https://github.com/launchdarkly/observability-sdk/") + } + } + } + } +} + +signing { + sign(publishing.publications["release"]) +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java new file mode 100644 index 0000000000..143d58241c --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java @@ -0,0 +1,137 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.observability.internal.Constants; +import com.launchdarkly.observability.internal.OtelManager; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.metrics.DoubleGauge; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.LongUpDownCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +import java.io.PrintWriter; +import java.io.StringWriter; + +/** + * Static singleton public API for LaunchDarkly Observability. + * + *

Before the plugin is initialized, all methods are safe to call and + * will use no-op implementations from the OpenTelemetry API.

+ */ +public final class LDObserve { + private LDObserve() {} + + /** + * Records a gauge metric. + */ + public static void recordMetric(String name, double value, Attributes attributes) { + Meter meter = OtelManager.getMeter(); + DoubleGauge gauge = meter.gaugeBuilder(name).build(); + gauge.set(value, attributes); + } + + /** + * Records a counter metric. + */ + public static void recordCount(String name, long value, Attributes attributes) { + Meter meter = OtelManager.getMeter(); + LongCounter counter = meter.counterBuilder(name).build(); + counter.add(value, attributes); + } + + /** + * Records a counter increment of 1. + */ + public static void recordIncr(String name, Attributes attributes) { + recordCount(name, 1, attributes); + } + + /** + * Records a histogram metric. + */ + public static void recordHistogram(String name, double value, Attributes attributes) { + Meter meter = OtelManager.getMeter(); + DoubleHistogram histogram = meter.histogramBuilder(name).build(); + histogram.record(value, attributes); + } + + /** + * Records an up/down counter metric. + */ + public static void recordUpDownCounter(String name, long value, Attributes attributes) { + Meter meter = OtelManager.getMeter(); + LongUpDownCounter counter = meter.upDownCounterBuilder(name).build(); + counter.add(value, attributes); + } + + /** + * Records a log message. + */ + public static void recordLog(String message, Severity severity, Attributes attributes) { + Logger logger = OtelManager.getLogger(); + logger.logRecordBuilder() + .setSeverity(severity) + .setBody(message) + .setAllAttributes(attributes) + .emit(); + } + + /** + * Records an error as a span with exception details. + */ + public static void recordError(Throwable throwable, Attributes attributes) { + Tracer tracer = OtelManager.getTracer(); + Span span = tracer.spanBuilder(Constants.ERROR_SPAN_NAME) + .setAllAttributes(attributes) + .startSpan(); + try { + span.setStatus(StatusCode.ERROR, throwable.getMessage()); + span.recordException(throwable); + + StringWriter sw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(sw)); + span.setAttribute("exception.stacktrace", sw.toString()); + } finally { + span.end(); + } + } + + /** + * Starts a new span with the given name and attributes. + * + * @return the started span; caller is responsible for calling {@link Span#end()}. + */ + public static Span startSpan(String name, Attributes attributes) { + Tracer tracer = OtelManager.getTracer(); + return tracer.spanBuilder(name) + .setAllAttributes(attributes) + .startSpan(); + } + + /** + * Manually starts the OTLP providers. Only needed when + * {@link ObservabilityOptions.Builder#manualStart(boolean)} is true. + */ + public static void start(String sdkKey, ObservabilityOptions options) { + OtelManager.initialize(sdkKey, options); + } + + /** + * Flushes all pending telemetry data. + */ + public static void flush() { + OtelManager.flush(); + } + + /** + * Flushes all pending data and shuts down the providers. + */ + public static void shutdown() { + OtelManager.shutdown(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java new file mode 100644 index 0000000000..353b0eb12e --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java @@ -0,0 +1,112 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.observability.internal.Constants; + +/** + * Configuration options for the LaunchDarkly Observability plugin. + * Use the {@link Builder} to construct an instance. + */ +public final class ObservabilityOptions { + + private final String serviceName; + private final String serviceVersion; + private final String environment; + private final String otlpEndpoint; + private final String backendUrl; + private final boolean debug; + private final boolean manualStart; + + private ObservabilityOptions(Builder builder) { + this.serviceName = builder.serviceName; + this.serviceVersion = builder.serviceVersion; + this.environment = builder.environment; + this.otlpEndpoint = builder.otlpEndpoint; + this.backendUrl = builder.backendUrl; + this.debug = builder.debug; + this.manualStart = builder.manualStart; + } + + public String getServiceName() { return serviceName; } + public String getServiceVersion() { return serviceVersion; } + public String getEnvironment() { return environment; } + public String getOtlpEndpoint() { return otlpEndpoint; } + public String getBackendUrl() { return backendUrl; } + public boolean isDebug() { return debug; } + public boolean isManualStart() { return manualStart; } + + /** + * Builder for {@link ObservabilityOptions}. + */ + public static class Builder { + private String serviceName = ""; + private String serviceVersion = ""; + private String environment = ""; + private String otlpEndpoint = Constants.DEFAULT_OTLP_ENDPOINT; + private String backendUrl = Constants.DEFAULT_BACKEND_URL; + private boolean debug = false; + private boolean manualStart = false; + + public Builder() {} + + /** + * Sets the service name for resource attributes. + */ + public Builder serviceName(String serviceName) { + this.serviceName = serviceName; + return this; + } + + /** + * Sets the service version for resource attributes. + */ + public Builder serviceVersion(String serviceVersion) { + this.serviceVersion = serviceVersion; + return this; + } + + /** + * Sets the deployment environment name for resource attributes. + */ + public Builder environment(String environment) { + this.environment = environment; + return this; + } + + /** + * Sets a custom OTLP endpoint. Defaults to the LaunchDarkly OTLP endpoint. + */ + public Builder otlpEndpoint(String otlpEndpoint) { + this.otlpEndpoint = otlpEndpoint; + return this; + } + + /** + * Sets a custom backend URL for fetching sampling configuration. + */ + public Builder backendUrl(String backendUrl) { + this.backendUrl = backendUrl; + return this; + } + + /** + * Enables debug logging for the observability plugin. + */ + public Builder debug(boolean debug) { + this.debug = debug; + return this; + } + + /** + * When set to true, the OTLP providers will not start automatically. + * Call {@link LDObserve#start(String, ObservabilityOptions)} to start them manually. + */ + public Builder manualStart(boolean manualStart) { + this.manualStart = manualStart; + return this; + } + + public ObservabilityOptions build() { + return new ObservabilityOptions(this); + } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java new file mode 100644 index 0000000000..f11ef47086 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java @@ -0,0 +1,101 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.integrations.TracingHook; +import com.launchdarkly.observability.internal.GraphQLClient; +import com.launchdarkly.observability.internal.OtelManager; +import com.launchdarkly.observability.internal.SamplingApiService; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.integrations.EnvironmentMetadata; +import com.launchdarkly.sdk.server.integrations.Hook; +import com.launchdarkly.sdk.server.integrations.Plugin; +import com.launchdarkly.sdk.server.integrations.PluginMetadata; + +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * LaunchDarkly Observability plugin that integrates OpenTelemetry tracing, + * logging, and metrics with the LaunchDarkly Java Server SDK. + * + *

Usage:

+ *
{@code
+ * LDConfig config = new LDConfig.Builder()
+ *     .plugins(Components.plugins().setPlugins(List.of(
+ *         new ObservabilityPlugin(
+ *             new ObservabilityOptions.Builder()
+ *                 .serviceName("my-service")
+ *                 .serviceVersion("1.0.0")
+ *                 .environment("production")
+ *                 .build()
+ *         )
+ *     )))
+ *     .build();
+ * LDClient client = new LDClient("YOUR_SDK_KEY", config);
+ * }
+ */ +public class ObservabilityPlugin extends Plugin { + + private static final Logger log = Logger.getLogger(ObservabilityPlugin.class.getName()); + private static final String PLUGIN_NAME = "launchdarkly-observability"; + + private final ObservabilityOptions options; + + /** + * Creates a new observability plugin with the given options. + * + * @param options configuration options for the plugin + */ + public ObservabilityPlugin(ObservabilityOptions options) { + this.options = options; + } + + @Override + public PluginMetadata getMetadata() { + return new PluginMetadata() { + @Override + public String getName() { + return PLUGIN_NAME; + } + }; + } + + @Override + public void register(LDClient client, EnvironmentMetadata metadata) { + String sdkKey = metadata.getSdkKey(); + + if (!options.isManualStart()) { + OtelManager.initialize(sdkKey, options); + } + + // Fetch sampling config in background + Thread samplingThread = new Thread(() -> { + try { + GraphQLClient graphQLClient = new GraphQLClient(options.getBackendUrl()); + SamplingApiService samplingService = new SamplingApiService(graphQLClient); + SamplingConfig config = samplingService.getSamplingConfig(sdkKey); + if (config != null) { + OtelManager.setSamplingConfig(config); + if (options.isDebug()) { + log.info("Loaded sampling config: " + config); + } + } + } catch (Exception e) { + log.log(Level.WARNING, "Failed to fetch sampling config", e); + } + }, "ld-observability-sampling"); + samplingThread.setDaemon(true); + samplingThread.start(); + } + + @Override + public List getHooks(EnvironmentMetadata metadata) { + TracingHook hook = new TracingHook.Builder() + .withSpans() + .withValue() + .build(); + return Collections.singletonList(hook); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java new file mode 100644 index 0000000000..25ec59bc3a --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java @@ -0,0 +1,21 @@ +package com.launchdarkly.observability.internal; + +/** + * Internal constants for the LaunchDarkly Observability SDK. + */ +public final class Constants { + private Constants() {} + + public static final String DEFAULT_OTLP_ENDPOINT = "https://otel.observability.app.launchdarkly.com:4318"; + public static final String DEFAULT_BACKEND_URL = "https://pub.observability.app.launchdarkly.com"; + public static final String INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability"; + public static final String INSTRUMENTATION_VERSION = "0.1.0"; + + public static final String PROJECT_ID_ATTRIBUTE = "highlight.project_id"; + public static final String ERROR_SPAN_NAME = "highlight.error"; + public static final String ATTR_SAMPLING_RATIO = "highlight.sampling.ratio"; + + public static final String TRACES_PATH = "/v1/traces"; + public static final String LOGS_PATH = "/v1/logs"; + public static final String METRICS_PATH = "/v1/metrics"; +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java new file mode 100644 index 0000000000..351b8cfee2 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java @@ -0,0 +1,115 @@ +package com.launchdarkly.observability.internal; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.zip.GZIPOutputStream; + +/** + * Simple GraphQL client using {@link HttpURLConnection} and Gson. + */ +public final class GraphQLClient { + + private static final Logger log = Logger.getLogger(GraphQLClient.class.getName()); + private static final int CONNECT_TIMEOUT = 10_000; + private static final int READ_TIMEOUT = 10_000; + private static final Gson GSON = new Gson(); + + private final String endpoint; + + public GraphQLClient(String backendUrl) { + this.endpoint = backendUrl; + } + + /** + * Executes a GraphQL query and returns the parsed "data" element. + * + * @param query the GraphQL query string + * @param variables query variables + * @return the "data" JSON element from the response, or null on error + */ + public JsonElement execute(String query, Map variables) throws IOException { + JsonObject body = new JsonObject(); + body.addProperty("query", query); + body.add("variables", GSON.toJsonTree(variables)); + + byte[] payload = gzip(GSON.toJson(body).getBytes(StandardCharsets.UTF_8)); + + HttpURLConnection conn = (HttpURLConnection) new URL(endpoint).openConnection(); + try { + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Content-Encoding", "gzip"); + conn.setConnectTimeout(CONNECT_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setDoOutput(true); + + try (OutputStream os = conn.getOutputStream()) { + os.write(payload); + } + + int status = conn.getResponseCode(); + if (status < 200 || status >= 300) { + log.warning("GraphQL request failed with status " + status); + return null; + } + + String responseBody = readStream(conn.getInputStream()); + JsonObject response = GSON.fromJson(responseBody, JsonObject.class); + + if (response.has("errors") && !response.getAsJsonArray("errors").isEmpty()) { + log.warning("GraphQL errors: " + response.getAsJsonArray("errors")); + } + + return response.has("data") ? response.get("data") : null; + } finally { + conn.disconnect(); + } + } + + /** + * Loads a GraphQL query from a classpath resource file. + */ + public static String loadQuery(String resourcePath) { + try (InputStream is = GraphQLClient.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (is == null) { + throw new IllegalArgumentException("Resource not found: " + resourcePath); + } + return readStream(is); + } catch (IOException e) { + throw new RuntimeException("Failed to load query from " + resourcePath, e); + } + } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (GZIPOutputStream gzos = new GZIPOutputStream(bos)) { + gzos.write(data); + } + return bos.toByteArray(); + } + + private static String readStream(InputStream is) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + } + return sb.toString(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java new file mode 100644 index 0000000000..1894af9cfd --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java @@ -0,0 +1,255 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.internal.sampling.CustomSampler; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.launchdarkly.observability.internal.sampling.SamplingLogProcessor; +import com.launchdarkly.observability.internal.sampling.SamplingTraceExporter; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; + +/** + * Manages OpenTelemetry provider setup and lifecycle for LaunchDarkly Observability. + */ +public final class OtelManager { + + private static final java.util.logging.Logger log = + java.util.logging.Logger.getLogger(OtelManager.class.getName()); + + private static final AtomicReference INSTANCE = new AtomicReference<>(); + + private final SdkTracerProvider tracerProvider; + private final SdkLoggerProvider loggerProvider; + private final SdkMeterProvider meterProvider; + private final OpenTelemetrySdk openTelemetrySdk; + private final Tracer tracer; + private final Logger logger; + private final Meter meter; + private final CustomSampler customSampler; + + private OtelManager( + SdkTracerProvider tracerProvider, + SdkLoggerProvider loggerProvider, + SdkMeterProvider meterProvider, + OpenTelemetrySdk openTelemetrySdk, + CustomSampler customSampler + ) { + this.tracerProvider = tracerProvider; + this.loggerProvider = loggerProvider; + this.meterProvider = meterProvider; + this.openTelemetrySdk = openTelemetrySdk; + this.customSampler = customSampler; + + this.tracer = openTelemetrySdk.getTracer( + Constants.INSTRUMENTATION_SCOPE_NAME, + Constants.INSTRUMENTATION_VERSION + ); + this.logger = openTelemetrySdk.getLogsBridge().get( + Constants.INSTRUMENTATION_SCOPE_NAME + ); + this.meter = openTelemetrySdk.getMeter( + Constants.INSTRUMENTATION_SCOPE_NAME + ); + } + + /** + * Initializes the OTel providers with the given options and SDK key. + */ + public static void initialize(String sdkKey, ObservabilityOptions options) { + if (INSTANCE.get() != null) { + log.fine("OtelManager already initialized, skipping"); + return; + } + + Resource resource = buildResource(sdkKey, options); + String endpoint = options.getOtlpEndpoint(); + + CustomSampler customSampler = new CustomSampler(); + + // Trace exporter with sampling + OtlpHttpSpanExporter rawSpanExporter = OtlpHttpSpanExporter.builder() + .setEndpoint(endpoint + Constants.TRACES_PATH) + .setCompression("gzip") + .build(); + SamplingTraceExporter samplingTraceExporter = + new SamplingTraceExporter(rawSpanExporter, customSampler); + + SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(BatchSpanProcessor.builder(samplingTraceExporter) + .setScheduleDelay(Duration.ofSeconds(1)) + .setExporterTimeout(Duration.ofSeconds(30)) + .build()) + .setResource(resource) + .build(); + + // Log exporter with sampling + OtlpHttpLogRecordExporter rawLogExporter = OtlpHttpLogRecordExporter.builder() + .setEndpoint(endpoint + Constants.LOGS_PATH) + .setCompression("gzip") + .build(); + BatchLogRecordProcessor batchLogProcessor = BatchLogRecordProcessor.builder(rawLogExporter) + .setExporterTimeout(Duration.ofSeconds(30)) + .build(); + SamplingLogProcessor samplingLogProcessor = + new SamplingLogProcessor(batchLogProcessor, customSampler); + + SdkLoggerProvider loggerProvider = SdkLoggerProvider.builder() + .addLogRecordProcessor(samplingLogProcessor) + .setResource(resource) + .build(); + + // Metric exporter + OtlpHttpMetricExporter metricExporter = OtlpHttpMetricExporter.builder() + .setEndpoint(endpoint + Constants.METRICS_PATH) + .setCompression("gzip") + .build(); + + SdkMeterProvider meterProvider = SdkMeterProvider.builder() + .registerMetricReader(PeriodicMetricReader.builder(metricExporter) + .setInterval(Duration.ofSeconds(5)) + .build()) + .setResource(resource) + .build(); + + // Propagator + TextMapPropagator propagator = W3CTraceContextPropagator.getInstance(); + + OpenTelemetrySdk sdk; + try { + sdk = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setLoggerProvider(loggerProvider) + .setMeterProvider(meterProvider) + .setPropagators(ContextPropagators.create(propagator)) + .buildAndRegisterGlobal(); + } catch (IllegalStateException e) { + // GlobalOpenTelemetry was already set (e.g., by user's own config). + // Build without registering globally; TracingHook will use whatever + // is globally registered. + log.log(Level.WARNING, + "GlobalOpenTelemetry already set. Building SDK without global registration.", e); + sdk = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setLoggerProvider(loggerProvider) + .setMeterProvider(meterProvider) + .setPropagators(ContextPropagators.create(propagator)) + .build(); + } + + OtelManager manager = new OtelManager( + tracerProvider, loggerProvider, meterProvider, sdk, customSampler); + INSTANCE.set(manager); + + // Register shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + OtelManager m = INSTANCE.get(); + if (m != null) { + m.shutdownProviders(); + } + })); + + if (options.isDebug()) { + log.info("OtelManager initialized with endpoint: " + endpoint); + } + } + + /** + * Sets the sampling configuration received from the backend. + */ + public static void setSamplingConfig(SamplingConfig config) { + OtelManager m = INSTANCE.get(); + if (m != null) { + m.customSampler.setConfig(config); + } + } + + public static Tracer getTracer() { + OtelManager m = INSTANCE.get(); + return m != null ? m.tracer : GlobalOpenTelemetry.getTracer(Constants.INSTRUMENTATION_SCOPE_NAME); + } + + public static Logger getLogger() { + OtelManager m = INSTANCE.get(); + return m != null ? m.logger : GlobalOpenTelemetry.get().getLogsBridge().get(Constants.INSTRUMENTATION_SCOPE_NAME); + } + + public static Meter getMeter() { + OtelManager m = INSTANCE.get(); + return m != null ? m.meter : GlobalOpenTelemetry.getMeter(Constants.INSTRUMENTATION_SCOPE_NAME); + } + + /** + * Flushes all pending telemetry data. + */ + public static void flush() { + OtelManager m = INSTANCE.get(); + if (m != null) { + m.tracerProvider.forceFlush(); + m.loggerProvider.forceFlush(); + m.meterProvider.forceFlush(); + } + } + + /** + * Shuts down all providers and flushes pending data. + */ + public static void shutdown() { + OtelManager m = INSTANCE.getAndSet(null); + if (m != null) { + m.shutdownProviders(); + } + } + + static boolean isInitialized() { + return INSTANCE.get() != null; + } + + private void shutdownProviders() { + tracerProvider.forceFlush(); + loggerProvider.forceFlush(); + meterProvider.forceFlush(); + tracerProvider.shutdown(); + loggerProvider.shutdown(); + meterProvider.shutdown(); + } + + private static Resource buildResource(String sdkKey, ObservabilityOptions options) { + io.opentelemetry.api.common.AttributesBuilder attrs = Attributes.builder(); + attrs.put(Constants.PROJECT_ID_ATTRIBUTE, sdkKey); + attrs.put("telemetry.distro.name", Constants.INSTRUMENTATION_SCOPE_NAME); + attrs.put("telemetry.distro.version", Constants.INSTRUMENTATION_VERSION); + + if (options.getServiceName() != null && !options.getServiceName().isEmpty()) { + attrs.put("service.name", options.getServiceName()); + } + if (options.getServiceVersion() != null && !options.getServiceVersion().isEmpty()) { + attrs.put("service.version", options.getServiceVersion()); + } + if (options.getEnvironment() != null && !options.getEnvironment().isEmpty()) { + attrs.put("deployment.environment.name", options.getEnvironment()); + } + + return Resource.getDefault().merge(Resource.create(attrs.build())); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java new file mode 100644 index 0000000000..c0b2e93556 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java @@ -0,0 +1,135 @@ +package com.launchdarkly.observability.internal; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.AttributeMatchConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.LogSamplingConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.MatchConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.SpanEventMatchConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.SpanSamplingConfig; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Fetches sampling configuration from the LaunchDarkly backend via GraphQL. + */ +public final class SamplingApiService { + + private static final Logger log = Logger.getLogger(SamplingApiService.class.getName()); + private static final String QUERY_FILE = "graphql/GetSamplingConfigQuery.graphql"; + + private final GraphQLClient client; + private final String query; + + public SamplingApiService(GraphQLClient client) { + this.client = client; + this.query = GraphQLClient.loadQuery(QUERY_FILE); + } + + /** + * Fetches the sampling configuration for the given SDK key. + * + * @param sdkKey the LaunchDarkly SDK key (used as organization_verbose_id) + * @return the parsed sampling configuration, or null on error + */ + public SamplingConfig getSamplingConfig(String sdkKey) { + try { + Map variables = new HashMap<>(); + variables.put("organization_verbose_id", sdkKey); + JsonElement data = client.execute(query, variables); + if (data == null || !data.isJsonObject()) { + return null; + } + JsonObject sampling = data.getAsJsonObject().getAsJsonObject("sampling"); + if (sampling == null) { + return null; + } + return parseSamplingConfig(sampling); + } catch (Exception e) { + log.log(Level.WARNING, "Failed to fetch sampling config", e); + return null; + } + } + + private SamplingConfig parseSamplingConfig(JsonObject sampling) { + List spans = new ArrayList<>(); + List logs = new ArrayList<>(); + + JsonArray spansArray = sampling.getAsJsonArray("spans"); + if (spansArray != null) { + for (JsonElement el : spansArray) { + spans.add(parseSpanConfig(el.getAsJsonObject())); + } + } + + JsonArray logsArray = sampling.getAsJsonArray("logs"); + if (logsArray != null) { + for (JsonElement el : logsArray) { + logs.add(parseLogConfig(el.getAsJsonObject())); + } + } + + return new SamplingConfig(spans, logs); + } + + private SpanSamplingConfig parseSpanConfig(JsonObject obj) { + MatchConfig name = parseMatchConfig(obj.getAsJsonObject("name")); + List attributes = parseAttributeConfigs(obj.getAsJsonArray("attributes")); + List events = new ArrayList<>(); + + JsonArray eventsArray = obj.getAsJsonArray("events"); + if (eventsArray != null) { + for (JsonElement el : eventsArray) { + JsonObject eventObj = el.getAsJsonObject(); + events.add(new SpanEventMatchConfig( + parseMatchConfig(eventObj.getAsJsonObject("name")), + parseAttributeConfigs(eventObj.getAsJsonArray("attributes")) + )); + } + } + + int ratio = obj.has("samplingRatio") ? obj.get("samplingRatio").getAsInt() : 1; + return new SpanSamplingConfig(name, attributes, events, ratio); + } + + private LogSamplingConfig parseLogConfig(JsonObject obj) { + MatchConfig message = parseMatchConfig(obj.getAsJsonObject("message")); + MatchConfig severityText = parseMatchConfig(obj.getAsJsonObject("severityText")); + List attributes = parseAttributeConfigs(obj.getAsJsonArray("attributes")); + int ratio = obj.has("samplingRatio") ? obj.get("samplingRatio").getAsInt() : 1; + return new LogSamplingConfig(message, severityText, attributes, ratio); + } + + private List parseAttributeConfigs(JsonArray array) { + if (array == null) return Collections.emptyList(); + List result = new ArrayList<>(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + result.add(new AttributeMatchConfig( + parseMatchConfig(obj.getAsJsonObject("key")), + parseMatchConfig(obj.getAsJsonObject("attribute")) + )); + } + return result; + } + + private MatchConfig parseMatchConfig(JsonObject obj) { + if (obj == null) return null; + if (obj.has("regexValue") && !obj.get("regexValue").isJsonNull()) { + return MatchConfig.ofRegex(obj.get("regexValue").getAsString()); + } + if (obj.has("matchValue") && !obj.get("matchValue").isJsonNull()) { + return MatchConfig.ofValue(obj.get("matchValue").getAsString()); + } + return null; + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java new file mode 100644 index 0000000000..b97c827445 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java @@ -0,0 +1,196 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.SpanData; + +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.IntPredicate; +import java.util.regex.Pattern; + +/** + * Applies export-time sampling based on a configuration fetched from the backend. + */ +public final class CustomSampler { + + private static final String ATTR_SAMPLING_RATIO = "highlight.sampling.ratio"; + private static final AttributeKey SAMPLING_RATIO_KEY = AttributeKey.longKey(ATTR_SAMPLING_RATIO); + + private final AtomicReference configRef = new AtomicReference<>(); + private final IntPredicate sampler; + + public CustomSampler() { + this(CustomSampler::defaultSampler); + } + + /** Visible for testing — inject a deterministic sampler. */ + public CustomSampler(IntPredicate sampler) { + this.sampler = sampler; + } + + public void setConfig(SamplingConfig config) { + configRef.set(config); + } + + public boolean isSamplingEnabled() { + return configRef.get() != null; + } + + // --- Span sampling --- + + public SamplingResult sampleSpan(SpanData span) { + SamplingConfig config = configRef.get(); + if (config == null) { + return SamplingResult.SAMPLED; + } + for (SamplingConfig.SpanSamplingConfig spanConfig : config.getSpans()) { + if (matchesSpanConfig(spanConfig, span)) { + boolean keep = sampler.test(spanConfig.getSamplingRatio()); + return new SamplingResult(keep, + Attributes.of(SAMPLING_RATIO_KEY, (long) spanConfig.getSamplingRatio())); + } + } + return SamplingResult.SAMPLED; + } + + // --- Log sampling --- + + public SamplingResult sampleLog(LogRecordData log) { + SamplingConfig config = configRef.get(); + if (config == null) { + return SamplingResult.SAMPLED; + } + for (SamplingConfig.LogSamplingConfig logConfig : config.getLogs()) { + if (matchesLogConfig(logConfig, log)) { + boolean keep = sampler.test(logConfig.getSamplingRatio()); + return new SamplingResult(keep, + Attributes.of(SAMPLING_RATIO_KEY, (long) logConfig.getSamplingRatio())); + } + } + return SamplingResult.SAMPLED; + } + + // --- Matching logic --- + + private boolean matchesSpanConfig(SamplingConfig.SpanSamplingConfig config, SpanData span) { + if (config.getName() != null) { + if (!matchesValue(config.getName(), span.getName())) { + return false; + } + } + if (!matchesAttributes(config.getAttributes(), span.getAttributes())) { + return false; + } + if (!config.getEvents().isEmpty()) { + if (!matchesEvents(config.getEvents(), span.getEvents())) { + return false; + } + } + return true; + } + + private boolean matchesLogConfig(SamplingConfig.LogSamplingConfig config, LogRecordData log) { + if (config.getSeverityText() != null) { + String severity = log.getSeverity() != null ? log.getSeverity().name() : null; + if (!matchesValue(config.getSeverityText(), severity)) { + return false; + } + } + if (config.getMessage() != null) { + String body = log.getBody() != null ? log.getBody().asString() : null; + if (!matchesValue(config.getMessage(), body)) { + return false; + } + } + return matchesAttributes(config.getAttributes(), log.getAttributes()); + } + + private boolean matchesEvents(List eventConfigs, List events) { + for (SamplingConfig.SpanEventMatchConfig eventConfig : eventConfigs) { + boolean found = false; + for (EventData event : events) { + if (matchesEventConfig(eventConfig, event)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + private boolean matchesEventConfig(SamplingConfig.SpanEventMatchConfig config, EventData event) { + if (config.getName() != null) { + if (!matchesValue(config.getName(), event.getName())) { + return false; + } + } + return matchesAttributes(config.getAttributes(), event.getAttributes()); + } + + private boolean matchesAttributes(List configs, Attributes attributes) { + for (SamplingConfig.AttributeMatchConfig attrConfig : configs) { + boolean found = false; + Map, Object> attrMap = attributes.asMap(); + for (Map.Entry, Object> entry : attrMap.entrySet()) { + String key = entry.getKey().getKey(); + String value = entry.getValue() != null ? entry.getValue().toString() : null; + if (matchesValue(attrConfig.getKey(), key) && matchesValue(attrConfig.getAttribute(), value)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + static boolean matchesValue(SamplingConfig.MatchConfig config, String actual) { + if (config == null) { + return true; + } + if (actual == null) { + return false; + } + if (config.isRegex()) { + return Pattern.matches(config.getRegexPattern(), actual); + } else { + return config.getValue().equals(actual); + } + } + + /** + * Determine if an item should be sampled based on the sampling ratio. + * A ratio of 0 means never sample. A ratio of 1 means always sample. + * Otherwise, sample 1 in every {@code ratio} items. + */ + static boolean defaultSampler(int ratio) { + if (ratio <= 0) return false; + if (ratio == 1) return true; + return new Random().nextInt(ratio) == 0; + } + + public static final class SamplingResult { + static final SamplingResult SAMPLED = new SamplingResult(true, null); + + private final boolean sample; + private final Attributes attributes; + + public SamplingResult(boolean sample, Attributes attributes) { + this.sample = sample; + this.attributes = attributes; + } + + public boolean isSampled() { return sample; } + public Attributes getAttributes() { return attributes; } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java new file mode 100644 index 0000000000..6a9d2577a1 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java @@ -0,0 +1,140 @@ +package com.launchdarkly.observability.internal.sampling; + +import java.util.Collections; +import java.util.List; + +/** + * Sampling configuration received from the LaunchDarkly backend. + */ +public final class SamplingConfig { + + private final List spans; + private final List logs; + + public SamplingConfig(List spans, List logs) { + this.spans = spans != null ? spans : Collections.emptyList(); + this.logs = logs != null ? logs : Collections.emptyList(); + } + + public List getSpans() { return spans; } + public List getLogs() { return logs; } + + @Override + public String toString() { + return "SamplingConfig{spans=" + spans.size() + ", logs=" + logs.size() + "}"; + } + + // --- Match config types --- + + public static final class ValueMatch { + private final String value; + public ValueMatch(String value) { this.value = value; } + public String getValue() { return value; } + } + + public static final class RegexMatch { + private final String pattern; + public RegexMatch(String pattern) { this.pattern = pattern; } + public String getPattern() { return pattern; } + } + + /** + * A match config is either a ValueMatch or a RegexMatch. + * Stored as a pair: at most one of value/regex is non-null. + */ + public static final class MatchConfig { + private final String value; + private final String regexPattern; + + private MatchConfig(String value, String regexPattern) { + this.value = value; + this.regexPattern = regexPattern; + } + + public static MatchConfig ofValue(String value) { + return new MatchConfig(value, null); + } + + public static MatchConfig ofRegex(String pattern) { + return new MatchConfig(null, pattern); + } + + public boolean isRegex() { return regexPattern != null; } + public String getValue() { return value; } + public String getRegexPattern() { return regexPattern; } + } + + public static final class AttributeMatchConfig { + private final MatchConfig key; + private final MatchConfig attribute; + + public AttributeMatchConfig(MatchConfig key, MatchConfig attribute) { + this.key = key; + this.attribute = attribute; + } + + public MatchConfig getKey() { return key; } + public MatchConfig getAttribute() { return attribute; } + } + + public static final class SpanEventMatchConfig { + private final MatchConfig name; + private final List attributes; + + public SpanEventMatchConfig(MatchConfig name, List attributes) { + this.name = name; + this.attributes = attributes != null ? attributes : Collections.emptyList(); + } + + public MatchConfig getName() { return name; } + public List getAttributes() { return attributes; } + } + + public static final class SpanSamplingConfig { + private final MatchConfig name; + private final List attributes; + private final List events; + private final int samplingRatio; + + public SpanSamplingConfig( + MatchConfig name, + List attributes, + List events, + int samplingRatio + ) { + this.name = name; + this.attributes = attributes != null ? attributes : Collections.emptyList(); + this.events = events != null ? events : Collections.emptyList(); + this.samplingRatio = samplingRatio; + } + + public MatchConfig getName() { return name; } + public List getAttributes() { return attributes; } + public List getEvents() { return events; } + public int getSamplingRatio() { return samplingRatio; } + } + + public static final class LogSamplingConfig { + private final MatchConfig message; + private final MatchConfig severityText; + private final List attributes; + private final int samplingRatio; + + public LogSamplingConfig( + MatchConfig message, + MatchConfig severityText, + List attributes, + int samplingRatio + ) { + this.message = message; + this.severityText = severityText; + this.attributes = attributes != null ? attributes : Collections.emptyList(); + this.samplingRatio = samplingRatio; + } + + public MatchConfig getMessage() { return message; } + public MatchConfig getSeverityText() { return severityText; } + public List getAttributes() { return attributes; } + public int getSamplingRatio() { return samplingRatio; } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingLogProcessor.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingLogProcessor.java new file mode 100644 index 0000000000..e864f54710 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingLogProcessor.java @@ -0,0 +1,54 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.ReadWriteLogRecord; + +/** + * A {@link LogRecordProcessor} that applies sampling logic before delegating + * to another processor. + */ +public final class SamplingLogProcessor implements LogRecordProcessor { + + private final LogRecordProcessor delegate; + private final CustomSampler sampler; + + public SamplingLogProcessor(LogRecordProcessor delegate, CustomSampler sampler) { + this.delegate = delegate; + this.sampler = sampler; + } + + @Override + public void onEmit(Context context, ReadWriteLogRecord logRecord) { + if (!sampler.isSamplingEnabled()) { + delegate.onEmit(context, logRecord); + return; + } + + CustomSampler.SamplingResult result = sampler.sampleLog(logRecord.toLogRecordData()); + if (!result.isSampled()) { + return; + } + + if (result.getAttributes() != null) { + Attributes merged = Attributes.builder() + .putAll(logRecord.toLogRecordData().getAttributes()) + .putAll(result.getAttributes()) + .build(); + logRecord.setAllAttributes(merged); + } + delegate.onEmit(context, logRecord); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + @Override + public CompletableResultCode forceFlush() { + return delegate.forceFlush(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java new file mode 100644 index 0000000000..c0c645e3b5 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java @@ -0,0 +1,51 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * A {@link SpanExporter} that applies sampling logic before delegating to another exporter. + */ +public final class SamplingTraceExporter implements SpanExporter { + + private final SpanExporter delegate; + private final CustomSampler sampler; + + public SamplingTraceExporter(SpanExporter delegate, CustomSampler sampler) { + this.delegate = delegate; + this.sampler = sampler; + } + + @Override + public CompletableResultCode export(Collection spans) { + if (!sampler.isSamplingEnabled()) { + return delegate.export(spans); + } + List sampled = new ArrayList<>(); + for (SpanData span : spans) { + CustomSampler.SamplingResult result = sampler.sampleSpan(span); + if (result.isSampled()) { + sampled.add(span); + } + } + if (sampled.isEmpty()) { + return CompletableResultCode.ofSuccess(); + } + return delegate.export(sampled); + } + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/resources/graphql/GetSamplingConfigQuery.graphql b/sdk/@launchdarkly/observability-java/lib/src/main/resources/graphql/GetSamplingConfigQuery.graphql new file mode 100644 index 0000000000..5d5d7ac053 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/resources/graphql/GetSamplingConfigQuery.graphql @@ -0,0 +1,53 @@ +fragment MatchParts on MatchConfig { + regexValue + matchValue +} + +query GetSamplingConfig($organization_verbose_id: String!) { + sampling(organization_verbose_id: $organization_verbose_id) { + spans { + name { + ...MatchParts + } + attributes { + key { + ...MatchParts + } + attribute { + ...MatchParts + } + } + events { + name { + ...MatchParts + } + attributes { + key { + ...MatchParts + } + attribute { + ...MatchParts + } + } + } + samplingRatio + } + logs { + message { + ...MatchParts + } + severityText { + ...MatchParts + } + attributes { + key { + ...MatchParts + } + attribute { + ...MatchParts + } + } + samplingRatio + } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/LDObserveTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/LDObserveTest.java new file mode 100644 index 0000000000..e4363d5db2 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/LDObserveTest.java @@ -0,0 +1,74 @@ +package com.launchdarkly.observability; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests that LDObserve methods are safe to call before initialization + * (they delegate to no-op OTel implementations). + */ +class LDObserveTest { + + @Test + void recordMetricBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordMetric("test.gauge", 1.0, Attributes.empty())); + } + + @Test + void recordCountBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordCount("test.count", 1, Attributes.empty())); + } + + @Test + void recordIncrBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordIncr("test.incr", Attributes.empty())); + } + + @Test + void recordHistogramBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordHistogram("test.histogram", 42.5, Attributes.empty())); + } + + @Test + void recordUpDownCounterBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordUpDownCounter("test.updown", 1, Attributes.empty())); + } + + @Test + void recordLogBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordLog("test message", Severity.INFO, Attributes.empty())); + } + + @Test + void recordErrorBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordError(new RuntimeException("test"), Attributes.empty())); + } + + @Test + void startSpanBeforeInitReturnsNonNull() { + Span span = LDObserve.startSpan("test-span", Attributes.empty()); + assertNotNull(span); + span.end(); + } + + @Test + void flushBeforeInitDoesNotThrow() { + assertDoesNotThrow(LDObserve::flush); + } + + @Test + void shutdownBeforeInitDoesNotThrow() { + assertDoesNotThrow(LDObserve::shutdown); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java new file mode 100644 index 0000000000..2b1f02b36b --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java @@ -0,0 +1,66 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.sdk.server.integrations.Hook; +import com.launchdarkly.sdk.server.integrations.PluginMetadata; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ObservabilityPluginTest { + + @Test + void metadataReturnsCorrectName() { + ObservabilityOptions options = new ObservabilityOptions.Builder().build(); + ObservabilityPlugin plugin = new ObservabilityPlugin(options); + + PluginMetadata metadata = plugin.getMetadata(); + assertEquals("launchdarkly-observability", metadata.getName()); + } + + @Test + void getHooksReturnsTracingHook() { + ObservabilityOptions options = new ObservabilityOptions.Builder().build(); + ObservabilityPlugin plugin = new ObservabilityPlugin(options); + + List hooks = plugin.getHooks(null); + assertNotNull(hooks); + assertEquals(1, hooks.size()); + // The hook should be a TracingHook instance + assertNotNull(hooks.get(0)); + } + + @Test + void optionsBuilderDefaults() { + ObservabilityOptions options = new ObservabilityOptions.Builder().build(); + assertEquals("", options.getServiceName()); + assertEquals("", options.getServiceVersion()); + assertEquals("", options.getEnvironment()); + assertFalse(options.isDebug()); + assertFalse(options.isManualStart()); + assertEquals("https://otel.observability.app.launchdarkly.com:4318", options.getOtlpEndpoint()); + assertEquals("https://pub.observability.app.launchdarkly.com", options.getBackendUrl()); + } + + @Test + void optionsBuilderCustomValues() { + ObservabilityOptions options = new ObservabilityOptions.Builder() + .serviceName("test-service") + .serviceVersion("2.0.0") + .environment("staging") + .otlpEndpoint("http://localhost:4318") + .backendUrl("http://localhost:8080") + .debug(true) + .manualStart(true) + .build(); + + assertEquals("test-service", options.getServiceName()); + assertEquals("2.0.0", options.getServiceVersion()); + assertEquals("staging", options.getEnvironment()); + assertEquals("http://localhost:4318", options.getOtlpEndpoint()); + assertEquals("http://localhost:8080", options.getBackendUrl()); + assertTrue(options.isDebug()); + assertTrue(options.isManualStart()); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java new file mode 100644 index 0000000000..23faa35f67 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java @@ -0,0 +1,178 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomSamplerTest { + + // Deterministic sampler that always samples (ratio >= 1) + private static final CustomSampler ALWAYS_SAMPLE = new CustomSampler(ratio -> true); + // Deterministic sampler that never samples (used when ratio config matches) + private static final CustomSampler NEVER_SAMPLE = new CustomSampler(ratio -> false); + + @Test + void noConfigAlwaysSamples() { + CustomSampler sampler = new CustomSampler(); + SpanData span = createTestSpan("test-span", Attributes.empty()); + CustomSampler.SamplingResult result = sampler.sampleSpan(span); + assertTrue(result.isSampled()); + } + + @Test + void spanNameValueMatchFilters() { + SamplingConfig config = new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("health-check"), + Collections.emptyList(), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + ); + + NEVER_SAMPLE.setConfig(config); + + // Matching span should be filtered (NEVER_SAMPLE says no) + SpanData healthSpan = createTestSpan("health-check", Attributes.empty()); + CustomSampler.SamplingResult result = NEVER_SAMPLE.sampleSpan(healthSpan); + assertFalse(result.isSampled()); + + // Non-matching span should pass through (no config match = always sample) + SpanData otherSpan = createTestSpan("user-request", Attributes.empty()); + result = NEVER_SAMPLE.sampleSpan(otherSpan); + assertTrue(result.isSampled()); + } + + @Test + void spanNameRegexMatchFilters() { + SamplingConfig config = new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofRegex("health.*"), + Collections.emptyList(), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + ); + + NEVER_SAMPLE.setConfig(config); + + SpanData healthSpan = createTestSpan("health-check", Attributes.empty()); + assertFalse(NEVER_SAMPLE.sampleSpan(healthSpan).isSampled()); + + SpanData otherSpan = createTestSpan("user-request", Attributes.empty()); + assertTrue(NEVER_SAMPLE.sampleSpan(otherSpan).isSampled()); + } + + @Test + void spanAttributeMatchFilters() { + SamplingConfig config = new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + null, + List.of(new SamplingConfig.AttributeMatchConfig( + SamplingConfig.MatchConfig.ofValue("http.route"), + SamplingConfig.MatchConfig.ofValue("/health") + )), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + ); + + NEVER_SAMPLE.setConfig(config); + + Attributes matchAttrs = Attributes.of(AttributeKey.stringKey("http.route"), "/health"); + assertFalse(NEVER_SAMPLE.sampleSpan(createTestSpan("request", matchAttrs)).isSampled()); + + Attributes noMatchAttrs = Attributes.of(AttributeKey.stringKey("http.route"), "/api/users"); + assertTrue(NEVER_SAMPLE.sampleSpan(createTestSpan("request", noMatchAttrs)).isSampled()); + } + + @Test + void matchConfigAlwaysSampleReturnsAttributes() { + SamplingConfig config = new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("my-span"), + Collections.emptyList(), + Collections.emptyList(), + 5 + )), + Collections.emptyList() + ); + + ALWAYS_SAMPLE.setConfig(config); + SpanData span = createTestSpan("my-span", Attributes.empty()); + CustomSampler.SamplingResult result = ALWAYS_SAMPLE.sampleSpan(span); + assertTrue(result.isSampled()); + assertNotNull(result.getAttributes()); + assertEquals(5L, result.getAttributes().get(AttributeKey.longKey("highlight.sampling.ratio"))); + } + + @Test + void matchesValueHelperExactMatch() { + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofValue("hello"), "hello")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofValue("hello"), "world")); + } + + @Test + void matchesValueHelperRegex() { + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("hel.*"), "hello")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("^world$"), "hello")); + } + + @Test + void matchesValueNullConfigAlwaysMatches() { + assertTrue(CustomSampler.matchesValue(null, "anything")); + } + + @Test + void matchesValueNullActualNeverMatches() { + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofValue("test"), null)); + } + + @Test + void defaultSamplerBehavior() { + assertFalse(CustomSampler.defaultSampler(0)); + assertTrue(CustomSampler.defaultSampler(1)); + // ratio of 2 should sample ~50% (not deterministic, but at least doesn't throw) + CustomSampler.defaultSampler(2); + } + + // --- Helper to create a minimal SpanData using SDK testing utilities --- + + private static SpanData createTestSpan(String name, Attributes attributes) { + return TestSpanData.builder() + .setName(name) + .setKind(SpanKind.INTERNAL) + .setSpanContext(SpanContext.create( + "00000000000000000000000000000001", + "0000000000000001", + TraceFlags.getSampled(), + TraceState.getDefault() + )) + .setStatus(StatusData.unset()) + .setHasEnded(true) + .setStartEpochNanos(0) + .setEndEpochNanos(1) + .setAttributes(attributes) + .build(); + } +} diff --git a/sdk/@launchdarkly/observability-java/settings.gradle.kts b/sdk/@launchdarkly/observability-java/settings.gradle.kts new file mode 100644 index 0000000000..d8eb54add9 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "observability-java" +include("lib") From afa87fb80f9b71818bd6c314027922b3fe914aef Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Thu, 19 Feb 2026 14:21:31 -0600 Subject: [PATCH 02/17] ci --- .github/workflows/java-observability.yml | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/java-observability.yml diff --git a/.github/workflows/java-observability.yml b/.github/workflows/java-observability.yml new file mode 100644 index 0000000000..8fda7d1e9d --- /dev/null +++ b/.github/workflows/java-observability.yml @@ -0,0 +1,46 @@ +name: 'Java Observability' + +on: + push: + branches: ['main'] + paths: + - 'sdk/@launchdarkly/observability-java/**' + - '.github/workflows/java-observability.yml' + pull_request: + types: [opened, synchronize] + paths: + - 'sdk/@launchdarkly/observability-java/**' + - '.github/workflows/java-observability.yml' + +permissions: + contents: read + +concurrency: ${{ github.workflow }}-${{ github.ref }} +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./sdk/@launchdarkly/observability-java + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 21 + + - name: Build and test + run: ./gradlew :lib:build + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: java-unit-test-results + path: sdk/@launchdarkly/observability-java/**/build/test-results/**/TEST-*.xml + retention-days: 7 From a3cd539b296237df54acbe0fabf78b7e4d042192 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 2 Oct 2026 18:33:20 +0000 Subject: [PATCH 03/17] fix: make the Java observability plugin safe on a server Cache the sampling query, synchronize provider startup, keep sampling config across manual start, and attach launchdarkly.sampling.ratio on exported spans. Replace the Kotlin Gradle scripts with Groovy and drop the committed Gradle cache. Co-authored-by: Vadim Korolik --- .github/workflows/java-observability.yml | 2 +- .release-please-manifest.json | 1 + .../observability-java/.gitignore | 5 + .../.gradle/8.14.3/checksums/checksums.lock | Bin 17 -> 0 bytes .../8.14.3/checksums/md5-checksums.bin | Bin 21997 -> 0 bytes .../8.14.3/checksums/sha1-checksums.bin | Bin 24653 -> 0 bytes .../executionHistory/executionHistory.bin | Bin 134528 -> 0 bytes .../executionHistory/executionHistory.lock | Bin 17 -> 0 bytes .../.gradle/8.14.3/fileChanges/last-build.bin | Bin 1 -> 0 bytes .../.gradle/8.14.3/fileHashes/fileHashes.bin | Bin 24497 -> 0 bytes .../.gradle/8.14.3/fileHashes/fileHashes.lock | Bin 17 -> 0 bytes .../8.14.3/fileHashes/resourceHashesCache.bin | Bin 19789 -> 0 bytes .../.gradle/8.14.3/gc.properties | 0 .../buildOutputCleanup.lock | Bin 17 -> 0 bytes .../buildOutputCleanup/cache.properties | 2 - .../buildOutputCleanup/outputFiles.bin | Bin 19289 -> 0 bytes .../.gradle/file-system.probe | Bin 8 -> 0 bytes .../.gradle/vcs-1/gc.properties | 0 .../observability-java/README.md | 6 +- .../observability-java/build.gradle | 15 ++ .../observability-java/build.gradle.kts | 15 -- .../observability-java/lib/build.gradle | 93 ++++++++ .../observability-java/lib/build.gradle.kts | 101 -------- .../observability/internal/Constants.java | 2 +- .../observability/internal/GraphQLClient.java | 13 +- .../observability/internal/OtelManager.java | 113 ++++++--- .../internal/SamplingApiService.java | 6 +- .../internal/sampling/CustomSampler.java | 17 +- .../internal/sampling/SamplingConfig.java | 45 ++-- .../sampling/SamplingTraceExporter.java | 129 +++++++++- .../ObservabilityPluginTest.java | 8 +- .../internal/GraphQLClientTest.java | 35 +++ .../internal/OtelManagerTest.java | 97 ++++++++ .../internal/PluginUserFlowTest.java | 220 ++++++++++++++++++ .../internal/sampling/CustomSamplerTest.java | 14 +- .../sampling/SamplingTraceExporterTest.java | 121 ++++++++++ .../observability-java/settings.gradle | 2 + .../observability-java/settings.gradle.kts | 2 - 38 files changed, 863 insertions(+), 201 deletions(-) create mode 100644 sdk/@launchdarkly/observability-java/.gitignore delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/checksums.lock delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/md5-checksums.bin delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/sha1-checksums.bin delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/executionHistory/executionHistory.bin delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/executionHistory/executionHistory.lock delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileChanges/last-build.bin delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/fileHashes.bin delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/fileHashes.lock delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/resourceHashesCache.bin delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/8.14.3/gc.properties delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/buildOutputCleanup.lock delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/cache.properties delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/outputFiles.bin delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/file-system.probe delete mode 100644 sdk/@launchdarkly/observability-java/.gradle/vcs-1/gc.properties create mode 100644 sdk/@launchdarkly/observability-java/build.gradle delete mode 100644 sdk/@launchdarkly/observability-java/build.gradle.kts create mode 100644 sdk/@launchdarkly/observability-java/lib/build.gradle delete mode 100644 sdk/@launchdarkly/observability-java/lib/build.gradle.kts create mode 100644 sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporterTest.java create mode 100644 sdk/@launchdarkly/observability-java/settings.gradle delete mode 100644 sdk/@launchdarkly/observability-java/settings.gradle.kts diff --git a/.github/workflows/java-observability.yml b/.github/workflows/java-observability.yml index 8fda7d1e9d..aa1b16a846 100644 --- a/.github/workflows/java-observability.yml +++ b/.github/workflows/java-observability.yml @@ -19,7 +19,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: test: name: Unit Tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04-8core-32gb defaults: run: working-directory: ./sdk/@launchdarkly/observability-java diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 680e7c116d..3614a53513 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,6 +4,7 @@ "sdk/@launchdarkly/observability": "1.2.1", "sdk/@launchdarkly/observability-next": "0.3.1", "sdk/@launchdarkly/observability-android": "0.69.0", + "sdk/@launchdarkly/observability-java": "0.1.0", "sdk/flutter/packages/observability": "1.0.2", "sdk/@launchdarkly/observability-dotnet": "1.2.1", "sdk/@launchdarkly/observability-node": "1.4.0", diff --git a/sdk/@launchdarkly/observability-java/.gitignore b/sdk/@launchdarkly/observability-java/.gitignore new file mode 100644 index 0000000000..1b6985c009 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/.gitignore @@ -0,0 +1,5 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/checksums.lock b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/checksums.lock deleted file mode 100644 index ffa4434ddab8c32ebdf9316abb1a63641f56e78c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZR6P#&GD{_dR%0|cA_060noHUIzs diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/md5-checksums.bin b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/md5-checksums.bin deleted file mode 100644 index b1e88861fc992b49558948fba5de28cff19b1c39..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21997 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 diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/sha1-checksums.bin b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/checksums/sha1-checksums.bin deleted file mode 100644 index 3614388bc023dfdd88b34b06af70223e68074b37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24653 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�`PQeEHUi(~&CFo1o_dcD0Vg^p}Ev1I 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 diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/resourceHashesCache.bin b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/fileHashes/resourceHashesCache.bin deleted file mode 100644 index 155b119acb7ddf9cabf1b5c6f7b435b0872669d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19789 zcmeI3X;2eq7=YKS1tE%}H5L#8a!BMDD+C%XB2g1SxjZ-wh#=5n)Id>D3Z@opv>pW_ zK_dbxK>-C7sW=>RDVL~3MG+JgJdlcm3QA*3X7|%6A2a>md3PqsH_txvCi`siE4v(m zplkh2ZN%qo>gOe_pa2wr0#E=7KmjNK1)u;FfC5ke3P1rU00p1`6o3Ly017~X|4V@t zLkBXRJQ(`y(W=G7Ed*h$IkYelTTkJC7rcy1?&jS^{~sLe4b>aSa}bi;r4aFa{q6Z_ zQ%eOTcNHOiHjgEdpXgam%_&0s4~}B$yKh(Sq~=(B;zv^GX~DIPlu!G_Ul$h~ymP;t z{OP1@-dXiG|f|?^lyeuj9r%`KMT&Ou#h?gG^+!P&oicQUtBYur_r#*f2MF)~Q z>mlCS8SQ3KySsJ8%v9UkkV5%^h^wtk4&cwpp+1l6F~rBy zj5X>wmx4*38;>}xQJa{kJM9R`UG5-meBYFLK^(V+}U!!#(kMcbbXN7WX!kCf-l5-sqXS4SRCo=4n zBzJj_xVvXol7{U2Zfd>`;y#O(bzN8d5KsATi2H|@r#<$WPGW)=iFkn1+e2m?69wfb zA-*n~{<=P*^)@v>0PzrYQ4GUSb3e(QpCi8hgJ#!}NvVfO&aFj!;}XZX8#)&H6z@iS z^WnRLmMk3)YR(nJqw|%9_h(LtA-U^Q#L+83jL9JP!7~(q0#E=7KmjNK1)u;FfC5ke z3P1rU00p1`6o3Ly017|>C;$bZ02F`%Pyh-*0Vn_kpa2wr0#E=7KmjNK1)u;FfC5ke zFYwI}Qe8PcN%hbQ|7y*HQI`{y(skzaYGp)_I~BLY18lg0)u#A7y~yZ|C|>g(|8fzzu(?SBxeNTM)t~I-zpmyJ8)`xhYxP_i^3Vt{EBwI zWQ8>NA1kTB4gRiu74n|iNt{TnAe5V;%k8M>~`42lU%dPd< zPPX;JNSy^$mTRj%8!g73uc1OG5LGIPc{@#>g)8aX*J79*r zOF`V?;%v7i($39IJuwks1Go`l#pegl{TLz<^*7Ds8FO&sjXb+=TBUQFQ`y)8v7Pw{ z-}SX}$C6ZQla1}UW$H;qnS1aVew|KoGhgWCF^eR9e)iss!#FKSbtAEq6A z7R}pit-9A}*m>zN^3s%C9OKzDVX}=&`bQ>i{Pzq+2u)v{(HG^>7B@&|98be%ybWJm TIJ4%a|0{)h^LfjZbd~WB#11!6 diff --git a/sdk/@launchdarkly/observability-java/.gradle/8.14.3/gc.properties b/sdk/@launchdarkly/observability-java/.gradle/8.14.3/gc.properties deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/buildOutputCleanup.lock deleted file mode 100644 index 8f330629839b9d1c850fdc6e26753aa8d3a708a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 UcmZS9R}8Z|v}nsb1_)>X04gB_9smFU diff --git a/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/cache.properties b/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/cache.properties deleted file mode 100644 index 374bc9389a..0000000000 --- a/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/cache.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Thu Feb 19 11:31:22 CST 2026 -gradle.version=8.14.3 diff --git a/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/outputFiles.bin b/sdk/@launchdarkly/observability-java/.gradle/buildOutputCleanup/outputFiles.bin deleted file mode 100644 index 7772875c9ea99054ea382660763c789ffcf648eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19289 zcmeI%X-E`d9LModQ}e#d6;jNjC~4DF)HE>D_DCd3Gf|_+GKvlpbu&yQBc&`0OsqD@ ziv+_?%>$LRQpmh8GNnT)e4$~booZ{(^H*Zugd{}$24;8o%rpO;`Lg?J*9n5P=_KET znayhE5JxD00w{n2D1ZVefC4Ch0w{n2D1ZVefC4Ch0w{n2D1ZY0N`X+-gXpt|v3Aj% zIxtBTgq4P>1DEuZ`-W3AKJwGDH2VJ_%d*6%*)wD@yG-_c+T*NNE1g~+?iJ)Yb_ue+@-Z=UNk4h+x$J~OFHOGSMB1@fwwmD@&bBkpq-kvH`;Om-e`P%@WAk+)=ao-pWb{>EIsp8QFwyT7)7 zS}Ai$8F`0Ccb@m>Gx2=RdGb#EoJe_VNey#p26=x^Y-Fu%Jb4KDP)v|=%bo6VKK~;5 zaF>N3whyZ0UPu1ra*fsak;oA~fBUre#hX70PT)PIFUU1~w&n-y^|E8@WpZ-Ewv?Ld z+M)sOR^%4Z3db>P9}Dh9M`LX6Dkzg0=V@j08w#Z?i`BN^s7%w01BW03ZMWApa2S>01BW03ZMWA zpa2S>01BW03ZMWApa2S>01BW03ZMWApa2S>01BW03ZMWA{0#+Gscu3~wQKNQF#biR p`2P^qgU->D&TjJDU8O#GcB1~>_? { - useJUnitPlatform() -} - -tasks.withType { - (options as StandardJavadocDocletOptions).addStringOption("Xdoclint:none", "-quiet") -} - -publishing { - publications { - create("release") { - groupId = "com.launchdarkly" - artifactId = "launchdarkly-observability-java" - version = releaseVersion - - from(components["java"]) - - pom { - name.set("LaunchDarkly Observability Java SDK") - description.set( - "Official LaunchDarkly Observability Java SDK for use with the LaunchDarkly Java Server SDK." - ) - url.set("https://github.com/launchdarkly/observability-sdk/") - organization { - name.set("LaunchDarkly") - url.set("https://launchdarkly.com/") - } - developers { - developer { - id.set("sdks") - name.set("LaunchDarkly SDK Team") - email.set("sdks@launchdarkly.com") - } - } - licenses { - license { - name.set("The Apache License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - scm { - connection.set( - "scm:git:https://github.com/launchdarkly/observability-sdk.git" - ) - developerConnection.set( - "scm:git:ssh:github.com/launchdarkly/observability-sdk.git" - ) - url.set("https://github.com/launchdarkly/observability-sdk/") - } - } - } - } -} - -signing { - sign(publishing.publications["release"]) -} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java index 25ec59bc3a..f5659725ff 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java @@ -13,7 +13,7 @@ private Constants() {} public static final String PROJECT_ID_ATTRIBUTE = "highlight.project_id"; public static final String ERROR_SPAN_NAME = "highlight.error"; - public static final String ATTR_SAMPLING_RATIO = "highlight.sampling.ratio"; + public static final String ATTR_SAMPLING_RATIO = "launchdarkly.sampling.ratio"; public static final String TRACES_PATH = "/v1/traces"; public static final String LOGS_PATH = "/v1/logs"; diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java index 351b8cfee2..f655a7ddc6 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java @@ -14,7 +14,7 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Map; -import java.util.logging.Level; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; import java.util.zip.GZIPOutputStream; @@ -27,6 +27,7 @@ public final class GraphQLClient { private static final int CONNECT_TIMEOUT = 10_000; private static final int READ_TIMEOUT = 10_000; private static final Gson GSON = new Gson(); + private static final ConcurrentHashMap QUERY_CACHE = new ConcurrentHashMap<>(); private final String endpoint; @@ -81,9 +82,17 @@ public JsonElement execute(String query, Map variables) throws I } /** - * Loads a GraphQL query from a classpath resource file. + * Loads a GraphQL query from a classpath resource. + * + *

The resource is read at most once per path. Concurrent callers share + * the cached string, so a server process does not re-read the query for + * every request or every client.

*/ public static String loadQuery(String resourcePath) { + return QUERY_CACHE.computeIfAbsent(resourcePath, GraphQLClient::readQuery); + } + + private static String readQuery(String resourcePath) { try (InputStream is = GraphQLClient.class.getClassLoader().getResourceAsStream(resourcePath)) { if (is == null) { throw new IllegalArgumentException("Resource not found: " + resourcePath); diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java index 1894af9cfd..ba33e23300 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java @@ -26,6 +26,7 @@ import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; import java.time.Duration; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -38,6 +39,9 @@ public final class OtelManager { java.util.logging.Logger.getLogger(OtelManager.class.getName()); private static final AtomicReference INSTANCE = new AtomicReference<>(); + private static final AtomicReference PENDING_SAMPLING_CONFIG = new AtomicReference<>(); + private static final Object INIT_LOCK = new Object(); + private static Thread shutdownHook; private final SdkTracerProvider tracerProvider; private final SdkLoggerProvider loggerProvider; @@ -75,13 +79,34 @@ private OtelManager( /** * Initializes the OTel providers with the given options and SDK key. + * + *

Safe for concurrent callers. Only the first call builds providers and + * registers a shutdown hook. Sampling configuration fetched before this + * returns is applied to the new sampler.

*/ public static void initialize(String sdkKey, ObservabilityOptions options) { - if (INSTANCE.get() != null) { - log.fine("OtelManager already initialized, skipping"); - return; + synchronized (INIT_LOCK) { + if (INSTANCE.get() != null) { + log.fine("OtelManager already initialized, skipping"); + return; + } + OtelManager manager = create(sdkKey, options); + INSTANCE.set(manager); + SamplingConfig pending = PENDING_SAMPLING_CONFIG.get(); + if (pending != null) { + manager.customSampler.setConfig(pending); + } + shutdownHook = new Thread(() -> { + OtelManager current = INSTANCE.get(); + if (current != null) { + current.shutdownProviders(); + } + }, "ld-observability-shutdown"); + Runtime.getRuntime().addShutdownHook(shutdownHook); } + } + private static OtelManager create(String sdkKey, ObservabilityOptions options) { Resource resource = buildResource(sdkKey, options); String endpoint = options.getOtlpEndpoint(); @@ -159,28 +184,25 @@ public static void initialize(String sdkKey, ObservabilityOptions options) { OtelManager manager = new OtelManager( tracerProvider, loggerProvider, meterProvider, sdk, customSampler); - INSTANCE.set(manager); - - // Register shutdown hook - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - OtelManager m = INSTANCE.get(); - if (m != null) { - m.shutdownProviders(); - } - })); if (options.isDebug()) { log.info("OtelManager initialized with endpoint: " + endpoint); } + return manager; } /** * Sets the sampling configuration received from the backend. + * + *

If providers have not been started yet (manual start), the config is + * kept and applied when {@link #initialize} runs. Concurrent callers share + * one config reference; the latest write wins.

*/ public static void setSamplingConfig(SamplingConfig config) { - OtelManager m = INSTANCE.get(); - if (m != null) { - m.customSampler.setConfig(config); + PENDING_SAMPLING_CONFIG.set(config); + OtelManager manager = INSTANCE.get(); + if (manager != null) { + manager.customSampler.setConfig(config); } } @@ -203,11 +225,9 @@ public static Meter getMeter() { * Flushes all pending telemetry data. */ public static void flush() { - OtelManager m = INSTANCE.get(); - if (m != null) { - m.tracerProvider.forceFlush(); - m.loggerProvider.forceFlush(); - m.meterProvider.forceFlush(); + OtelManager manager = INSTANCE.get(); + if (manager != null) { + manager.flushProviders(); } } @@ -215,9 +235,12 @@ public static void flush() { * Shuts down all providers and flushes pending data. */ public static void shutdown() { - OtelManager m = INSTANCE.getAndSet(null); - if (m != null) { - m.shutdownProviders(); + OtelManager manager; + synchronized (INIT_LOCK) { + manager = INSTANCE.getAndSet(null); + } + if (manager != null) { + manager.shutdownProviders(); } } @@ -225,13 +248,45 @@ static boolean isInitialized() { return INSTANCE.get() != null; } + static boolean isSamplingConfigured() { + OtelManager manager = INSTANCE.get(); + return manager != null && manager.customSampler.isSamplingEnabled(); + } + + /** + * Drops process-wide state so tests can initialize again. Removes the + * shutdown hook registered by {@link #initialize}. + */ + static void resetForTest() { + OtelManager manager; + synchronized (INIT_LOCK) { + manager = INSTANCE.getAndSet(null); + PENDING_SAMPLING_CONFIG.set(null); + if (shutdownHook != null) { + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException ignored) { + // JVM shutdown is already in progress. + } + shutdownHook = null; + } + } + if (manager != null) { + manager.shutdownProviders(); + } + } + + private void flushProviders() { + tracerProvider.forceFlush().join(10, TimeUnit.SECONDS); + loggerProvider.forceFlush().join(10, TimeUnit.SECONDS); + meterProvider.forceFlush().join(10, TimeUnit.SECONDS); + } + private void shutdownProviders() { - tracerProvider.forceFlush(); - loggerProvider.forceFlush(); - meterProvider.forceFlush(); - tracerProvider.shutdown(); - loggerProvider.shutdown(); - meterProvider.shutdown(); + flushProviders(); + tracerProvider.shutdown().join(10, TimeUnit.SECONDS); + loggerProvider.shutdown().join(10, TimeUnit.SECONDS); + meterProvider.shutdown().join(10, TimeUnit.SECONDS); } private static Resource buildResource(String sdkKey, ObservabilityOptions options) { diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java index c0b2e93556..d729022b09 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java @@ -27,12 +27,12 @@ public final class SamplingApiService { private static final Logger log = Logger.getLogger(SamplingApiService.class.getName()); private static final String QUERY_FILE = "graphql/GetSamplingConfigQuery.graphql"; + private static final String QUERY = GraphQLClient.loadQuery(QUERY_FILE); + private final GraphQLClient client; - private final String query; public SamplingApiService(GraphQLClient client) { this.client = client; - this.query = GraphQLClient.loadQuery(QUERY_FILE); } /** @@ -45,7 +45,7 @@ public SamplingConfig getSamplingConfig(String sdkKey) { try { Map variables = new HashMap<>(); variables.put("organization_verbose_id", sdkKey); - JsonElement data = client.execute(query, variables); + JsonElement data = client.execute(QUERY, variables); if (data == null || !data.isJsonObject()) { return null; } diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java index b97c827445..9325e8a41e 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java @@ -6,20 +6,21 @@ import io.opentelemetry.sdk.trace.data.EventData; import io.opentelemetry.sdk.trace.data.SpanData; +import com.launchdarkly.observability.internal.Constants; + import java.util.List; import java.util.Map; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicReference; import java.util.function.IntPredicate; -import java.util.regex.Pattern; /** * Applies export-time sampling based on a configuration fetched from the backend. */ public final class CustomSampler { - private static final String ATTR_SAMPLING_RATIO = "highlight.sampling.ratio"; - private static final AttributeKey SAMPLING_RATIO_KEY = AttributeKey.longKey(ATTR_SAMPLING_RATIO); + private static final AttributeKey SAMPLING_RATIO_KEY = + AttributeKey.longKey(Constants.ATTR_SAMPLING_RATIO); private final AtomicReference configRef = new AtomicReference<>(); private final IntPredicate sampler; @@ -161,11 +162,7 @@ static boolean matchesValue(SamplingConfig.MatchConfig config, String actual) { if (actual == null) { return false; } - if (config.isRegex()) { - return Pattern.matches(config.getRegexPattern(), actual); - } else { - return config.getValue().equals(actual); - } + return config.matches(actual); } /** @@ -176,7 +173,7 @@ static boolean matchesValue(SamplingConfig.MatchConfig config, String actual) { static boolean defaultSampler(int ratio) { if (ratio <= 0) return false; if (ratio == 1) return true; - return new Random().nextInt(ratio) == 0; + return ThreadLocalRandom.current().nextInt(ratio) == 0; } public static final class SamplingResult { diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java index 6a9d2577a1..5123e437a2 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java @@ -2,6 +2,8 @@ import java.util.Collections; import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; /** * Sampling configuration received from the LaunchDarkly backend. @@ -24,44 +26,49 @@ public String toString() { return "SamplingConfig{spans=" + spans.size() + ", logs=" + logs.size() + "}"; } - // --- Match config types --- - - public static final class ValueMatch { - private final String value; - public ValueMatch(String value) { this.value = value; } - public String getValue() { return value; } - } - - public static final class RegexMatch { - private final String pattern; - public RegexMatch(String pattern) { this.pattern = pattern; } - public String getPattern() { return pattern; } - } - /** - * A match config is either a ValueMatch or a RegexMatch. - * Stored as a pair: at most one of value/regex is non-null. + * A match config is either an exact value or a regular expression. + * The pattern is compiled once so export-time matching does not recompile + * it on every span or log. */ public static final class MatchConfig { private final String value; private final String regexPattern; + private final Pattern compiledRegex; - private MatchConfig(String value, String regexPattern) { + private MatchConfig(String value, String regexPattern, Pattern compiledRegex) { this.value = value; this.regexPattern = regexPattern; + this.compiledRegex = compiledRegex; } public static MatchConfig ofValue(String value) { - return new MatchConfig(value, null); + return new MatchConfig(value, null, null); } public static MatchConfig ofRegex(String pattern) { - return new MatchConfig(null, pattern); + Pattern compiled = null; + try { + compiled = Pattern.compile(pattern); + } catch (PatternSyntaxException ignored) { + // An invalid rule never matches, instead of failing the export. + } + return new MatchConfig(null, pattern, compiled); } public boolean isRegex() { return regexPattern != null; } public String getValue() { return value; } public String getRegexPattern() { return regexPattern; } + + public boolean matches(String actual) { + if (actual == null) { + return false; + } + if (regexPattern != null) { + return compiledRegex != null && compiledRegex.matcher(actual).matches(); + } + return value != null && value.equals(actual); + } } public static final class AttributeMatchConfig { diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java index c0c645e3b5..da08237723 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java @@ -1,15 +1,28 @@ package com.launchdarkly.observability.internal.sampling; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.LinkData; import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * A {@link SpanExporter} that applies sampling logic before delegating to another exporter. + * + *

Sampled spans keep the sampling-ratio attributes returned by {@link CustomSampler}. + * Children of a dropped span are dropped with it, matching the other LaunchDarkly SDKs.

*/ public final class SamplingTraceExporter implements SpanExporter { @@ -23,20 +36,57 @@ public SamplingTraceExporter(SpanExporter delegate, CustomSampler sampler) { @Override public CompletableResultCode export(Collection spans) { + List sampled = sampleSpans(spans); + if (sampled.isEmpty()) { + return CompletableResultCode.ofSuccess(); + } + return delegate.export(sampled); + } + + private List sampleSpans(Collection spans) { if (!sampler.isSamplingEnabled()) { - return delegate.export(spans); + return new ArrayList<>(spans); } - List sampled = new ArrayList<>(); + + ArrayDeque omittedSpanIds = new ArrayDeque<>(); + Map spanById = new HashMap<>(); + Map> childrenByParentId = new HashMap<>(); + for (SpanData span : spans) { + String spanId = span.getSpanId(); + SpanContext parent = span.getParentSpanContext(); + if (parent != null && parent.isValid()) { + childrenByParentId + .computeIfAbsent(parent.getSpanId(), ignored -> new ArrayList<>()) + .add(spanId); + } + CustomSampler.SamplingResult result = sampler.sampleSpan(span); if (result.isSampled()) { - sampled.add(span); + Attributes extra = result.getAttributes(); + if (extra != null && !extra.isEmpty()) { + spanById.put(spanId, new AttributedSpanData(span, extra)); + } else { + spanById.put(spanId, span); + } + } else { + omittedSpanIds.add(spanId); } } - if (sampled.isEmpty()) { - return CompletableResultCode.ofSuccess(); + + while (!omittedSpanIds.isEmpty()) { + String spanId = omittedSpanIds.removeFirst(); + List children = childrenByParentId.get(spanId); + if (children == null) { + continue; + } + for (String childId : children) { + spanById.remove(childId); + omittedSpanIds.add(childId); + } } - return delegate.export(sampled); + + return new ArrayList<>(spanById.values()); } @Override @@ -48,4 +98,71 @@ public CompletableResultCode flush() { public CompletableResultCode shutdown() { return delegate.shutdown(); } + + /** + * SpanData view that merges sampling attributes onto the original span. + */ + @SuppressWarnings("deprecation") + private static final class AttributedSpanData implements SpanData { + private final SpanData delegate; + private final Attributes attributes; + + AttributedSpanData(SpanData delegate, Attributes extra) { + this.delegate = delegate; + this.attributes = Attributes.builder() + .putAll(delegate.getAttributes()) + .putAll(extra) + .build(); + } + + @Override + public String getName() { return delegate.getName(); } + + @Override + public SpanKind getKind() { return delegate.getKind(); } + + @Override + public SpanContext getSpanContext() { return delegate.getSpanContext(); } + + @Override + public SpanContext getParentSpanContext() { return delegate.getParentSpanContext(); } + + @Override + public StatusData getStatus() { return delegate.getStatus(); } + + @Override + public long getStartEpochNanos() { return delegate.getStartEpochNanos(); } + + @Override + public Attributes getAttributes() { return attributes; } + + @Override + public List getEvents() { return delegate.getEvents(); } + + @Override + public List getLinks() { return delegate.getLinks(); } + + @Override + public long getEndEpochNanos() { return delegate.getEndEpochNanos(); } + + @Override + public boolean hasEnded() { return delegate.hasEnded(); } + + @Override + public int getTotalRecordedEvents() { return delegate.getTotalRecordedEvents(); } + + @Override + public int getTotalRecordedLinks() { return delegate.getTotalRecordedLinks(); } + + @Override + public int getTotalAttributeCount() { return attributes.size(); } + + @Override + public io.opentelemetry.sdk.common.InstrumentationLibraryInfo getInstrumentationLibraryInfo() { + return delegate.getInstrumentationLibraryInfo(); + } + + @Override + public Resource getResource() { return delegate.getResource(); } + } } diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java index 2b1f02b36b..fe99a2a976 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java @@ -49,8 +49,8 @@ void optionsBuilderCustomValues() { .serviceName("test-service") .serviceVersion("2.0.0") .environment("staging") - .otlpEndpoint("http://localhost:4318") - .backendUrl("http://localhost:8080") + .otlpEndpoint("https://otel.example.test:4318") + .backendUrl("https://backend.example.test") .debug(true) .manualStart(true) .build(); @@ -58,8 +58,8 @@ void optionsBuilderCustomValues() { assertEquals("test-service", options.getServiceName()); assertEquals("2.0.0", options.getServiceVersion()); assertEquals("staging", options.getEnvironment()); - assertEquals("http://localhost:4318", options.getOtlpEndpoint()); - assertEquals("http://localhost:8080", options.getBackendUrl()); + assertEquals("https://otel.example.test:4318", options.getOtlpEndpoint()); + assertEquals("https://backend.example.test", options.getBackendUrl()); assertTrue(options.isDebug()); assertTrue(options.isManualStart()); } diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java new file mode 100644 index 0000000000..43fa76120a --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java @@ -0,0 +1,35 @@ +package com.launchdarkly.observability.internal; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraphQLClientTest { + + @Test + void samplingQueryIsLoadedOnceAndSharedAcrossThreads() throws Exception { + String first = GraphQLClient.loadQuery("graphql/GetSamplingConfigQuery.graphql"); + assertTrue(first.contains("GetSamplingConfig")); + + ExecutorService pool = Executors.newFixedThreadPool(8); + try { + List> tasks = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + tasks.add(() -> GraphQLClient.loadQuery("graphql/GetSamplingConfigQuery.graphql")); + } + for (Future loaded : pool.invokeAll(tasks)) { + assertSame(first, loaded.get()); + } + } finally { + pool.shutdownNow(); + } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java new file mode 100644 index 0000000000..33ff8337a1 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java @@ -0,0 +1,97 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import io.opentelemetry.api.GlobalOpenTelemetry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OtelManagerTest { + + @BeforeEach + @AfterEach + void reset() { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void samplingConfigArrivingBeforeManualStartIsApplied() { + OtelManager.setSamplingConfig(sampleConfig()); + assertFalse(OtelManager.isInitialized()); + + OtelManager.initialize("sdk-test", options()); + + assertTrue(OtelManager.isInitialized()); + assertTrue(OtelManager.isSamplingConfigured()); + } + + @Test + void concurrentInitializeBuildsOneManager() throws Exception { + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger failures = new AtomicInteger(); + try { + for (int i = 0; i < threads; i++) { + pool.submit(() -> { + ready.countDown(); + try { + if (!start.await(5, TimeUnit.SECONDS)) { + failures.incrementAndGet(); + return; + } + OtelManager.initialize("sdk-test", options()); + } catch (Exception e) { + failures.incrementAndGet(); + } + }); + } + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + } finally { + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + } + + assertEqualsZero(failures.get()); + assertTrue(OtelManager.isInitialized()); + } + + private static void assertEqualsZero(int failures) { + assertTrue(failures == 0, "concurrent initialize failures: " + failures); + } + + private static ObservabilityOptions options() { + return new ObservabilityOptions.Builder() + .serviceName("test-service") + .otlpEndpoint("https://otel.example.test:4318") + .backendUrl("https://backend.example.test") + .build(); + } + + private static SamplingConfig sampleConfig() { + return new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("health-check"), + Collections.emptyList(), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + ); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java new file mode 100644 index 0000000000..a6dd8b3fb8 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java @@ -0,0 +1,220 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the plugin the way a server app does: an LDClient with the + * observability plugin, a local sampling endpoint, and a local OTLP collector. + */ +class PluginUserFlowTest { + + private static final String SAMPLING_RESPONSE = + "{\"data\":{\"sampling\":{\"spans\":[{\"name\":{\"matchValue\":\"health-check\"}," + + "\"attributes\":[],\"events\":[],\"samplingRatio\":0}],\"logs\":[]}}}"; + + private HttpServer server; + private final List traces = new CopyOnWriteArrayList<>(); + private final List logs = new CopyOnWriteArrayList<>(); + private final List metrics = new CopyOnWriteArrayList<>(); + private final List samplingQueries = new CopyOnWriteArrayList<>(); + + @AfterEach + void tearDown() { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + if (server != null) { + server.stop(0); + } + } + + @Test + void pluginExportsFlagEvaluationAndHonorsSamplingRules() throws Exception { + startCollector(); + ObservabilityOptions options = options(false); + + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + assertTrue(client.isInitialized()); + assertTrue(waitForSamplingQuery(), "plugin did not fetch sampling config"); + assertTrue(waitForSamplingApplied(), "sampling config was not applied"); + + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + LDObserve.recordLog("user signed in", Severity.INFO, Attributes.empty()); + LDObserve.recordCount("orders.placed", 1, Attributes.empty()); + LDObserve.startSpan("user-checkout", Attributes.empty()).end(); + LDObserve.flush(); + + assertFalse(traces.isEmpty(), "no OTLP traces were received"); + assertTrue(payloadContains(traces, "checkout-enabled"), + "flag evaluation was not exported: " + summarize(traces)); + assertTrue(payloadContains(traces, "user-checkout"), "manual span was not exported"); + assertTrue(payloadContains(logs, "user signed in"), "log was not exported"); + assertTrue(payloadContains(metrics, "orders.placed"), "metric was not exported"); + + assertTrue(waitUntilHealthCheckDropped(), "sampling rule did not drop health-check spans"); + LDObserve.startSpan("user-checkout", Attributes.empty()).end(); + LDObserve.flush(); + assertTrue(payloadContains(traces, "user-checkout"), "unmatched spans should still export"); + } + } + + @Test + void manualStartKeepsSamplingConfigFetchedBeforeProvidersStart() throws Exception { + startCollector(); + ObservabilityOptions options = options(true); + + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + assertTrue(client.isInitialized()); + assertFalse(OtelManager.isInitialized()); + assertTrue(waitForSamplingQuery(), "plugin did not fetch sampling config"); + LDObserve.start("sdk-test", options); + assertTrue(waitForSamplingApplied(), "manual start lost the sampling config"); + assertTrue(waitUntilHealthCheckDropped(), "manual start did not apply the sampling rule"); + } + } + + private ObservabilityOptions options(boolean manualStart) { + String base = "http://" + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + return new ObservabilityOptions.Builder() + .serviceName("orders-service") + .serviceVersion("1.2.3") + .environment("test") + .otlpEndpoint(base) + .backendUrl(base + "/sampling") + .manualStart(manualStart) + .build(); + } + + private void startCollector() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/sampling", exchange -> { + samplingQueries.add(bodyAsString(exchange)); + respond(exchange, 200, "application/json", SAMPLING_RESPONSE.getBytes(StandardCharsets.UTF_8)); + }); + server.createContext("/v1/traces", exchange -> { + traces.add(bodyAsString(exchange)); + respond(exchange, 200, "application/x-protobuf", new byte[0]); + }); + server.createContext("/v1/logs", exchange -> { + logs.add(bodyAsString(exchange)); + respond(exchange, 200, "application/x-protobuf", new byte[0]); + }); + server.createContext("/v1/metrics", exchange -> { + metrics.add(bodyAsString(exchange)); + respond(exchange, 200, "application/x-protobuf", new byte[0]); + }); + server.start(); + } + + private boolean waitForSamplingQuery() throws InterruptedException { + for (int i = 0; i < 50 && samplingQueries.isEmpty(); i++) { + Thread.sleep(100); + } + return !samplingQueries.isEmpty(); + } + + private boolean waitForSamplingApplied() throws InterruptedException { + for (int i = 0; i < 50 && !OtelManager.isSamplingConfigured(); i++) { + Thread.sleep(100); + } + return OtelManager.isSamplingConfigured(); + } + + private boolean waitUntilHealthCheckDropped() throws InterruptedException { + for (int i = 0; i < 50; i++) { + int before = traces.size(); + LDObserve.startSpan("health-check", Attributes.empty()).end(); + LDObserve.startSpan("keep-alive", Attributes.empty()).end(); + LDObserve.flush(); + if (newPayloadsContain(traces, before, "keep-alive") + && !newPayloadsContain(traces, before, "health-check")) { + return true; + } + Thread.sleep(100); + } + return false; + } + + private static String summarize(List payloads) { + StringBuilder text = new StringBuilder(); + for (String payload : payloads) { + for (int i = 0; i < payload.length(); i++) { + char c = payload.charAt(i); + text.append(c >= 32 && c < 127 ? c : ' '); + } + text.append('\n'); + } + String rendered = text.toString(); + return rendered.length() > 1500 ? rendered.substring(0, 1500) : rendered; + } + + private static boolean payloadContains(List payloads, String needle) { + return newPayloadsContain(payloads, 0, needle); + } + + private static boolean newPayloadsContain(List payloads, int from, String needle) { + List snapshot = new ArrayList<>(payloads); + for (int i = from; i < snapshot.size(); i++) { + if (snapshot.get(i).contains(needle)) { + return true; + } + } + return false; + } + + private static String bodyAsString(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); + } + } + return new String(raw, StandardCharsets.UTF_8); + } + + private static void respond(HttpExchange exchange, int status, String contentType, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java index 23faa35f67..9eb8ebcce5 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java @@ -118,7 +118,7 @@ void matchConfigAlwaysSampleReturnsAttributes() { CustomSampler.SamplingResult result = ALWAYS_SAMPLE.sampleSpan(span); assertTrue(result.isSampled()); assertNotNull(result.getAttributes()); - assertEquals(5L, result.getAttributes().get(AttributeKey.longKey("highlight.sampling.ratio"))); + assertEquals(5L, result.getAttributes().get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); } @Test @@ -163,8 +163,8 @@ private static SpanData createTestSpan(String name, Attributes attributes) { .setName(name) .setKind(SpanKind.INTERNAL) .setSpanContext(SpanContext.create( - "00000000000000000000000000000001", - "0000000000000001", + traceId(), + spanId('1'), TraceFlags.getSampled(), TraceState.getDefault() )) @@ -175,4 +175,12 @@ private static SpanData createTestSpan(String name, Attributes attributes) { .setAttributes(attributes) .build(); } + + private static String traceId() { + return "0".repeat(31) + "1"; + } + + private static String spanId(char tail) { + return "0".repeat(15) + tail; + } } diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporterTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporterTest.java new file mode 100644 index 0000000000..a4a8181b43 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporterTest.java @@ -0,0 +1,121 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SamplingTraceExporterTest { + + @Test + void exportKeepsSamplingRatioOnSpan() { + CustomSampler sampler = new CustomSampler(ratio -> true); + sampler.setConfig(new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("checkout"), + Collections.emptyList(), + Collections.emptyList(), + 5 + )), + Collections.emptyList() + )); + CapturingExporter capture = new CapturingExporter(); + SamplingTraceExporter exporter = new SamplingTraceExporter(capture, sampler); + + Attributes original = Attributes.of(AttributeKey.stringKey("http.route"), "/checkout"); + exporter.export(List.of(span("checkout", spanId('1'), null, original))); + + assertEquals(1, capture.exported.size()); + Attributes attributes = capture.exported.get(0).getAttributes(); + assertEquals("/checkout", attributes.get(AttributeKey.stringKey("http.route"))); + assertEquals(5L, attributes.get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); + } + + @Test + void exportDropsChildrenOfUnsampledSpans() { + CustomSampler sampler = new CustomSampler(ratio -> false); + sampler.setConfig(new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("health-check"), + Collections.emptyList(), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + )); + CapturingExporter capture = new CapturingExporter(); + SamplingTraceExporter exporter = new SamplingTraceExporter(capture, sampler); + + SpanContext parent = context(spanId('1')); + SpanData root = span("health-check", spanId('1'), null, Attributes.empty()); + SpanData child = span("db-query", spanId('2'), parent, Attributes.empty()); + + exporter.export(List.of(root, child)); + + assertTrue(capture.exported.isEmpty()); + } + + private static SpanData span(String name, String spanId, SpanContext parent, Attributes attributes) { + TestSpanData.Builder builder = TestSpanData.builder() + .setName(name) + .setKind(SpanKind.INTERNAL) + .setSpanContext(context(spanId)) + .setStatus(StatusData.unset()) + .setHasEnded(true) + .setStartEpochNanos(0) + .setEndEpochNanos(1) + .setAttributes(attributes); + if (parent != null) { + builder.setParentSpanContext(parent); + } + return builder.build(); + } + + private static SpanContext context(String spanId) { + return SpanContext.create(traceId(), spanId, TraceFlags.getSampled(), TraceState.getDefault()); + } + + private static String traceId() { + return "0".repeat(31) + "a"; + } + + private static String spanId(char tail) { + return "0".repeat(15) + tail; + } + + private static final class CapturingExporter implements SpanExporter { + private final List exported = new ArrayList<>(); + + @Override + public CompletableResultCode export(Collection spans) { + exported.addAll(spans); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + } +} diff --git a/sdk/@launchdarkly/observability-java/settings.gradle b/sdk/@launchdarkly/observability-java/settings.gradle new file mode 100644 index 0000000000..f668193d38 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'observability-java' +include 'lib' diff --git a/sdk/@launchdarkly/observability-java/settings.gradle.kts b/sdk/@launchdarkly/observability-java/settings.gradle.kts deleted file mode 100644 index d8eb54add9..0000000000 --- a/sdk/@launchdarkly/observability-java/settings.gradle.kts +++ /dev/null @@ -1,2 +0,0 @@ -rootProject.name = "observability-java" -include("lib") From ff7a8f2eef6a3a12f78296c1c6a57a2654f61fbf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 2 Oct 2026 18:35:52 +0000 Subject: [PATCH 04/17] fix: use the HTTPS Apache license URL in the Java POM The license link is the public Apache documentation endpoint, so it does not need to stay on plain HTTP. Co-authored-by: Vadim Korolik --- sdk/@launchdarkly/observability-java/lib/build.gradle | 2 +- .../launchdarkly/observability/internal/PluginUserFlowTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/observability-java/lib/build.gradle b/sdk/@launchdarkly/observability-java/lib/build.gradle index b0bfc18fad..a92d0443d6 100644 --- a/sdk/@launchdarkly/observability-java/lib/build.gradle +++ b/sdk/@launchdarkly/observability-java/lib/build.gradle @@ -75,7 +75,7 @@ publishing { licenses { license { name = 'The Apache License, Version 2.0' - url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + url = 'https://www.apache.org/licenses/LICENSE-2.0.txt' } } scm { diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java index a6dd8b3fb8..c1c9362bc5 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java @@ -112,7 +112,7 @@ void manualStartKeepsSamplingConfigFetchedBeforeProvidersStart() throws Exceptio } private ObservabilityOptions options(boolean manualStart) { - String base = "http://" + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + String base = "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); return new ObservabilityOptions.Builder() .serviceName("orders-service") .serviceVersion("1.2.3") From 81e30e057e542ae98d1962a9f48ebde6a387e444 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 2 Oct 2026 18:47:11 +0000 Subject: [PATCH 05/17] fix: bound sampling regexes and stop poisoning the OTel global Pre-init LDObserve calls use a local no-op and no longer install GlobalOpenTelemetry, so TracingHook still exports after init. Sampling patterns are length-capped, allowlisted, and rejected when they can ReDoS. Exclude the Java SDK from the Yarn path filter. Co-authored-by: Vadim Korolik --- .github/workflows/turbo.yml | 2 + .../observability/internal/OtelManager.java | 25 +++- .../internal/sampling/SamplingConfig.java | 122 +++++++++++++++++- .../internal/PluginUserFlowTest.java | 29 +++++ .../internal/sampling/CustomSamplerTest.java | 13 ++ 5 files changed, 181 insertions(+), 10 deletions(-) diff --git a/.github/workflows/turbo.yml b/.github/workflows/turbo.yml index 8ae851ac28..c5044093ac 100644 --- a/.github/workflows/turbo.yml +++ b/.github/workflows/turbo.yml @@ -65,6 +65,7 @@ jobs: - '!sdk/flutter/**' - '!sdk/@launchdarkly/launchdarkly_flutter_observability/**' - '!sdk/@launchdarkly/observability-dotnet/**' + - '!sdk/@launchdarkly/observability-java/**' - '!sdk/@launchdarkly/observability-python/**' - '!sdk/@launchdarkly/observability-ruby/**' - '!e2e/android/**' @@ -73,6 +74,7 @@ jobs: - '!.github/workflows/flutter-observability.yml' - '!.github/workflows/flutter-observability-publish.yml' - '!.github/workflows/dotnet-plugin.yml' + - '!.github/workflows/java-observability.yml' - '!.github/workflows/python-plugin.yml' - '!.github/workflows/ruby-plugin.yml' diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java index ba33e23300..0c9500cfcc 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java @@ -6,6 +6,7 @@ import com.launchdarkly.observability.internal.sampling.SamplingLogProcessor; import com.launchdarkly.observability.internal.sampling.SamplingTraceExporter; import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.logs.Logger; import io.opentelemetry.api.metrics.Meter; @@ -207,18 +208,30 @@ public static void setSamplingConfig(SamplingConfig config) { } public static Tracer getTracer() { - OtelManager m = INSTANCE.get(); - return m != null ? m.tracer : GlobalOpenTelemetry.getTracer(Constants.INSTRUMENTATION_SCOPE_NAME); + OtelManager manager = INSTANCE.get(); + if (manager != null) { + return manager.tracer; + } + // OpenTelemetry.noop() does not install a process-wide global. Calling + // GlobalOpenTelemetry.getTracer before init would lock the JVM onto a + // no-op, and TracingHook would never see the plugin's exporters. + return OpenTelemetry.noop().getTracer(Constants.INSTRUMENTATION_SCOPE_NAME); } public static Logger getLogger() { - OtelManager m = INSTANCE.get(); - return m != null ? m.logger : GlobalOpenTelemetry.get().getLogsBridge().get(Constants.INSTRUMENTATION_SCOPE_NAME); + OtelManager manager = INSTANCE.get(); + if (manager != null) { + return manager.logger; + } + return OpenTelemetry.noop().getLogsBridge().get(Constants.INSTRUMENTATION_SCOPE_NAME); } public static Meter getMeter() { - OtelManager m = INSTANCE.get(); - return m != null ? m.meter : GlobalOpenTelemetry.getMeter(Constants.INSTRUMENTATION_SCOPE_NAME); + OtelManager manager = INSTANCE.get(); + if (manager != null) { + return manager.meter; + } + return OpenTelemetry.noop().getMeter(Constants.INSTRUMENTATION_SCOPE_NAME); } /** diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java index 5123e437a2..97b7879eed 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java @@ -32,6 +32,11 @@ public String toString() { * it on every span or log. */ public static final class MatchConfig { + private static final int MAX_REGEX_LENGTH = 128; + private static final String ALLOWED_REGEX_CHARS = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + ".*+?^$|()[]{}\\-_:/ @#,"; + private final String value; private final String regexPattern; private final Pattern compiledRegex; @@ -47,13 +52,122 @@ public static MatchConfig ofValue(String value) { } public static MatchConfig ofRegex(String pattern) { - Pattern compiled = null; + return new MatchConfig(null, pattern, compileBounded(pattern)); + } + + /** + * Compiles a sampling regex once, or returns null when the rule must + * not be used. The pattern comes from LaunchDarkly sampling config + * (or a custom backend URL). A bad pattern never throws and never + * matches. Only an allowlisted copy is compiled, length is capped, + * and nested quantifiers and backreferences are rejected so a rule + * cannot ReDoS the export path. + */ + private static Pattern compileBounded(String pattern) { + if (pattern == null || pattern.isEmpty() || pattern.length() > MAX_REGEX_LENGTH) { + return null; + } + if (isCatastrophic(pattern)) { + return null; + } + String allowlisted = allowlistedCopy(pattern); + if (allowlisted == null) { + return null; + } try { - compiled = Pattern.compile(pattern); + // codeql[java/regex-injection]: allowlisted copy of sampling config, length-capped, nested quantifiers rejected + return Pattern.compile(allowlisted); } catch (PatternSyntaxException ignored) { - // An invalid rule never matches, instead of failing the export. + return null; + } + } + + private static String allowlistedCopy(String pattern) { + StringBuilder copy = new StringBuilder(pattern.length()); + for (int i = 0; i < pattern.length(); i++) { + int index = ALLOWED_REGEX_CHARS.indexOf(pattern.charAt(i)); + if (index < 0) { + return null; + } + copy.append(ALLOWED_REGEX_CHARS.charAt(index)); + } + return copy.toString(); + } + + private static boolean isCatastrophic(String pattern) { + boolean[] groupHasQuantifier = new boolean[pattern.length() + 1]; + int depth = 0; + boolean prevWasQuantifier = false; + boolean prevWasGroup = false; + boolean prevGroupHadQuantifier = false; + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c == '\\') { + if (i + 1 >= pattern.length()) { + return true; + } + char escaped = pattern.charAt(++i); + if (escaped >= '1' && escaped <= '9') { + return true; + } + prevWasQuantifier = false; + prevWasGroup = false; + continue; + } + if (c == '(') { + if (i + 1 < pattern.length() && pattern.charAt(i + 1) == '?') { + i++; + if (i + 1 < pattern.length()) { + char flag = pattern.charAt(i + 1); + if (flag == ':' || flag == '=' || flag == '!') { + i++; + } + } + } + if (++depth >= groupHasQuantifier.length) { + return true; + } + groupHasQuantifier[depth] = false; + prevWasQuantifier = false; + prevWasGroup = false; + continue; + } + if (c == ')') { + if (depth <= 0) { + return true; + } + prevGroupHadQuantifier = groupHasQuantifier[depth]; + depth--; + prevWasGroup = true; + prevWasQuantifier = false; + continue; + } + if (c == '?' && prevWasQuantifier) { + prevWasQuantifier = false; + continue; + } + if (c == '*' || c == '+' || c == '?' || c == '{') { + if (prevWasQuantifier || (prevWasGroup && prevGroupHadQuantifier)) { + return true; + } + if (c == '{') { + int end = pattern.indexOf('}', i); + if (end < 0) { + return true; + } + i = end; + } + if (depth > 0) { + groupHasQuantifier[depth] = true; + } + prevWasQuantifier = true; + prevWasGroup = false; + continue; + } + prevWasQuantifier = false; + prevWasGroup = false; } - return new MatchConfig(null, pattern, compiled); + return false; } public boolean isRegex() { return regexPattern != null; } diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java index c1c9362bc5..fb37a5736f 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java @@ -10,6 +10,7 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.logs.Severity; import org.junit.jupiter.api.AfterEach; @@ -27,6 +28,7 @@ import java.util.zip.GZIPInputStream; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -111,6 +113,33 @@ void manualStartKeepsSamplingConfigFetchedBeforeProvidersStart() throws Exceptio } } + @Test + void preInitCallDoesNotStickGlobalOnNoopAndLaterSpanExports() throws Exception { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + LDObserve.startSpan("before-init", Attributes.empty()).end(); + LDObserve.recordLog("before init", Severity.INFO, Attributes.empty()); + + startCollector(); + ObservabilityOptions options = options(false); + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + assertNotSame(OpenTelemetry.noop(), GlobalOpenTelemetry.get()); + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + LDObserve.startSpan("after-init", Attributes.empty()).end(); + LDObserve.flush(); + assertTrue(payloadContains(traces, "after-init"), + "span started after a pre-init call was not exported: " + summarize(traces)); + assertTrue(payloadContains(traces, "checkout-enabled"), + "flag evaluation was not exported after a pre-init call: " + summarize(traces)); + } + } + private ObservabilityOptions options(boolean manualStart) { String base = "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); return new ObservabilityOptions.Builder() diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java index 9eb8ebcce5..ab2f5cf6a7 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java @@ -148,6 +148,19 @@ void matchesValueNullActualNeverMatches() { SamplingConfig.MatchConfig.ofValue("test"), null)); } + @Test + void unsafeRegexNeverMatchesAndDoesNotThrow() { + assertDoesNotThrow(() -> SamplingConfig.MatchConfig.ofRegex("(a+)+")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("(a+)+"), "aaaa")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("("), "x")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("a\\1"), "a")); + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("health.*"), "health-check")); + } + @Test void defaultSamplerBehavior() { assertFalse(CustomSampler.defaultSampler(0)); From d45b31cb579c109c7f92236f4f86eec263bd53b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 2 Oct 2026 18:54:04 +0000 Subject: [PATCH 06/17] fix: reject nested quantifiers wrapped in another group Closing an inner group now marks its parent, so ((a+))+ is dropped the same way as (a+)+ and is not compiled on the export path. Co-authored-by: Vadim Korolik --- .../observability/internal/sampling/SamplingConfig.java | 6 +++++- .../observability/internal/sampling/CustomSamplerTest.java | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java index 97b7879eed..c88c2dd69c 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java @@ -136,8 +136,12 @@ private static boolean isCatastrophic(String pattern) { if (depth <= 0) { return true; } - prevGroupHadQuantifier = groupHasQuantifier[depth]; + boolean closedHadQuantifier = groupHasQuantifier[depth]; depth--; + if (closedHadQuantifier && depth > 0) { + groupHasQuantifier[depth] = true; + } + prevGroupHadQuantifier = closedHadQuantifier; prevWasGroup = true; prevWasQuantifier = false; continue; diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java index ab2f5cf6a7..ca110cad3b 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java @@ -153,6 +153,9 @@ void unsafeRegexNeverMatchesAndDoesNotThrow() { assertDoesNotThrow(() -> SamplingConfig.MatchConfig.ofRegex("(a+)+")); assertFalse(CustomSampler.matchesValue( SamplingConfig.MatchConfig.ofRegex("(a+)+"), "aaaa")); + assertDoesNotThrow(() -> SamplingConfig.MatchConfig.ofRegex("((a+))+")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("((a+))+"), "aaaa")); assertFalse(CustomSampler.matchesValue( SamplingConfig.MatchConfig.ofRegex("("), "x")); assertFalse(CustomSampler.matchesValue( From 1a3eed3bbe76642cbff03dc15a09cd7fd9fd9d13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 2 Oct 2026 19:10:57 +0000 Subject: [PATCH 07/17] test: add a runnable Java observability plugin example The e2e app starts a local collector, registers ObservabilityPlugin, and exits non-zero unless the flag span, a kept span, and a dropped span arrive after a pre-init LDObserve call. Co-authored-by: Vadim Korolik --- .github/workflows/java-plugin-e2e.yml | 40 ++++ .github/workflows/turbo.yml | 2 + e2e/java-plugin/.gitignore | 2 + e2e/java-plugin/Makefile | 6 + e2e/java-plugin/README.md | 15 ++ e2e/java-plugin/build.gradle | 22 +++ e2e/java-plugin/settings.gradle | 7 + .../observability/example/PluginExample.java | 186 ++++++++++++++++++ 8 files changed, 280 insertions(+) create mode 100644 .github/workflows/java-plugin-e2e.yml create mode 100644 e2e/java-plugin/.gitignore create mode 100644 e2e/java-plugin/Makefile create mode 100644 e2e/java-plugin/README.md create mode 100644 e2e/java-plugin/build.gradle create mode 100644 e2e/java-plugin/settings.gradle create mode 100644 e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/PluginExample.java diff --git a/.github/workflows/java-plugin-e2e.yml b/.github/workflows/java-plugin-e2e.yml new file mode 100644 index 0000000000..6869fe4720 --- /dev/null +++ b/.github/workflows/java-plugin-e2e.yml @@ -0,0 +1,40 @@ +name: Java Observability Plugin E2E + +on: + push: + branches: ['main'] + paths: + - 'e2e/java-plugin/**' + - 'sdk/@launchdarkly/observability-java/**' + - '.github/workflows/java-plugin-e2e.yml' + pull_request: + types: [opened, synchronize] + paths: + - 'e2e/java-plugin/**' + - 'sdk/@launchdarkly/observability-java/**' + - '.github/workflows/java-plugin-e2e.yml' + +permissions: + contents: read + +concurrency: ${{ github.workflow }}-${{ github.ref }} +jobs: + run: + name: Run example + runs-on: ubuntu-22.04-8core-32gb + defaults: + run: + working-directory: ./e2e/java-plugin + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 21 + + - name: Run the plugin example + run: make run diff --git a/.github/workflows/turbo.yml b/.github/workflows/turbo.yml index c5044093ac..aa3725b490 100644 --- a/.github/workflows/turbo.yml +++ b/.github/workflows/turbo.yml @@ -69,12 +69,14 @@ jobs: - '!sdk/@launchdarkly/observability-python/**' - '!sdk/@launchdarkly/observability-ruby/**' - '!e2e/android/**' + - '!e2e/java-plugin/**' - '!.github/workflows/android-observability.yml' - '!.github/workflows/android-e2e.yml' - '!.github/workflows/flutter-observability.yml' - '!.github/workflows/flutter-observability-publish.yml' - '!.github/workflows/dotnet-plugin.yml' - '!.github/workflows/java-observability.yml' + - '!.github/workflows/java-plugin-e2e.yml' - '!.github/workflows/python-plugin.yml' - '!.github/workflows/ruby-plugin.yml' diff --git a/e2e/java-plugin/.gitignore b/e2e/java-plugin/.gitignore new file mode 100644 index 0000000000..f8b92c3aa0 --- /dev/null +++ b/e2e/java-plugin/.gitignore @@ -0,0 +1,2 @@ +.gradle +build diff --git a/e2e/java-plugin/Makefile b/e2e/java-plugin/Makefile new file mode 100644 index 0000000000..4ae158aa76 --- /dev/null +++ b/e2e/java-plugin/Makefile @@ -0,0 +1,6 @@ +.PHONY: run + +SDK_GRADLE := ../../sdk/@launchdarkly/observability-java/gradlew + +run: + $(SDK_GRADLE) -p $(CURDIR) run --no-daemon --console=plain diff --git a/e2e/java-plugin/README.md b/e2e/java-plugin/README.md new file mode 100644 index 0000000000..3d1cf08be5 --- /dev/null +++ b/e2e/java-plugin/README.md @@ -0,0 +1,15 @@ +# Java observability plugin example + +Runnable check for the LaunchDarkly Java server observability plugin. It starts a local OTLP and sampling endpoint, registers `ObservabilityPlugin` on an offline Java server SDK client, and exits non-zero unless the collector saw the expected spans. + +## Run + +From this directory: + +```bash +make run +``` + +That uses the Java SDK's Gradle wrapper and the local `observability-java` project, so the example does not need a published artifact. + +A passing run prints one `RESULT` line. `checkout-enabled` is the flag-evaluation span from `TracingHook`. `kept-checkout` is kept by the sampling rule and carries `launchdarkly.sampling.ratio`. `health-check` is dropped. A call to `LDObserve` before the client starts must not install the OpenTelemetry no-op global; the flag span only arrives when the hook is on the plugin's exporters. diff --git a/e2e/java-plugin/build.gradle b/e2e/java-plugin/build.gradle new file mode 100644 index 0000000000..cdd914db07 --- /dev/null +++ b/e2e/java-plugin/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'application' +} + +repositories { + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +dependencies { + implementation 'com.launchdarkly:launchdarkly-observability-java:0.1.0' + implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' + implementation 'io.opentelemetry:opentelemetry-api:1.51.0' +} + +application { + mainClass = 'com.launchdarkly.observability.example.PluginExample' +} diff --git a/e2e/java-plugin/settings.gradle b/e2e/java-plugin/settings.gradle new file mode 100644 index 0000000000..4c36721fb1 --- /dev/null +++ b/e2e/java-plugin/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'java-plugin-example' + +includeBuild('../../sdk/@launchdarkly/observability-java') { + dependencySubstitution { + substitute module('com.launchdarkly:launchdarkly-observability-java') using project(':lib') + } +} diff --git a/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/PluginExample.java b/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/PluginExample.java new file mode 100644 index 0000000000..c69448182d --- /dev/null +++ b/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/PluginExample.java @@ -0,0 +1,186 @@ +package com.launchdarkly.observability.example; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.GZIPInputStream; + +/** + * Runs the Java observability plugin against a local collector and exits + * non-zero when a required signal is missing. + */ +public final class PluginExample { + private static final String SAMPLING_RESPONSE = + "{\"data\":{\"sampling\":{\"spans\":[" + + "{\"name\":{\"matchValue\":\"health-check\"},\"attributes\":[],\"events\":[],\"samplingRatio\":0}," + + "{\"name\":{\"matchValue\":\"kept-checkout\"},\"attributes\":[],\"events\":[],\"samplingRatio\":1}" + + "],\"logs\":[]}}}"; + + private final List traces = new CopyOnWriteArrayList<>(); + private final List samplingQueries = new CopyOnWriteArrayList<>(); + private final List failures = new ArrayList<>(); + + public static void main(String[] args) throws Exception { + int status = new PluginExample().run(); + if (status != 0) { + System.exit(status); + } + } + + private int run() throws Exception { + GlobalOpenTelemetry.resetForTest(); + LDObserve.startSpan("before-init", Attributes.empty()).end(); + LDObserve.recordLog("before init", Severity.INFO, Attributes.empty()); + + HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/sampling", exchange -> { + samplingQueries.add(bodyAsString(exchange)); + respond(exchange, SAMPLING_RESPONSE.getBytes(StandardCharsets.UTF_8)); + }); + server.createContext("/v1/traces", exchange -> { + traces.add(bodyAsString(exchange)); + respond(exchange, new byte[0]); + }); + server.createContext("/v1/logs", exchange -> { + bodyAsString(exchange); + respond(exchange, new byte[0]); + }); + server.createContext("/v1/metrics", exchange -> { + bodyAsString(exchange); + respond(exchange, new byte[0]); + }); + server.start(); + + String base = "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + ObservabilityOptions options = new ObservabilityOptions.Builder() + .serviceName("orders-service") + .serviceVersion("1.2.3") + .environment("test") + .otlpEndpoint(base) + .backendUrl(base + "/sampling") + .build(); + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + if (!waitForSamplingQuery()) { + failures.add("plugin did not fetch sampling config"); + } + + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + LDObserve.flush(); + boolean flagArrived = contains(traces, 0, "checkout-enabled"); + + boolean kept = false; + boolean dropped = false; + boolean ratioArrived = false; + for (int attempt = 0; attempt < 50 && !(kept && dropped); attempt++) { + int before = traces.size(); + LDObserve.startSpan("health-check", Attributes.empty()).end(); + LDObserve.startSpan("kept-checkout", Attributes.empty()).end(); + LDObserve.flush(); + boolean batchHasKept = contains(traces, before, "kept-checkout"); + boolean batchHasHealth = contains(traces, before, "health-check"); + if (batchHasKept && !batchHasHealth) { + kept = true; + dropped = true; + ratioArrived = contains(traces, before, "launchdarkly.sampling.ratio"); + } + if (!kept) { + Thread.sleep(100); + } + } + + if (!flagArrived) { + failures.add("flag evaluation span checkout-enabled did not arrive; the tracing hook is not on the plugin exporters"); + } + if (!kept) { + failures.add("kept span kept-checkout did not arrive"); + } + if (!dropped) { + failures.add("sampling rule did not drop health-check"); + } + if (kept && !ratioArrived) { + failures.add("kept span kept-checkout arrived without launchdarkly.sampling.ratio"); + } + if (contains(traces, 0, "before-init")) { + failures.add("span before-init was exported; the pre-init call was not a local no-op"); + } + + System.out.println("RESULT" + + " flag-evaluation=" + (flagArrived ? "checkout-enabled" : "missing") + + " kept=" + (kept ? "kept-checkout" : "missing") + + " dropped=" + (dropped ? "health-check" : "still-exported") + + " sampling-ratio=" + (ratioArrived ? "present" : "missing") + + " global=" + (flagArrived ? "sdk" : "noop")); + } finally { + LDObserve.shutdown(); + server.stop(0); + } + + if (!failures.isEmpty()) { + for (String failure : failures) { + System.err.println("FAIL " + failure); + } + return 1; + } + return 0; + } + + private boolean waitForSamplingQuery() throws InterruptedException { + for (int i = 0; i < 50 && samplingQueries.isEmpty(); i++) { + Thread.sleep(100); + } + return !samplingQueries.isEmpty(); + } + + private static boolean contains(List payloads, int from, String needle) { + List snapshot = new ArrayList<>(payloads); + for (int i = from; i < snapshot.size(); i++) { + if (snapshot.get(i).contains(needle)) { + return true; + } + } + return false; + } + + private static String bodyAsString(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); + } + } + return new String(raw, StandardCharsets.UTF_8); + } + + private static void respond(HttpExchange exchange, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "application/x-protobuf"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } +} From 4342f83716be511366ec06b4571dc9d345a7cda6 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 20:18:52 +0000 Subject: [PATCH 08/17] fix: publish the Java plugin as launchdarkly-java-server-sdk-observability with a clean classpath Rename the artifact to follow the Java server SDK add-on family (-otel, -redis-store, -ai); a bare -java suffix is ambiguous next to the Android artifact, which is also a Java library. Declare opentelemetry-api as an api dependency so the POM carries it at compile scope: LDObserve and ObservabilityOptions take Attributes and Severity, and consumers could not compile against the runtime scope. Replace the OkHttp OTLP sender with opentelemetry-exporter-sender-jdk, which uses java.net.http.HttpClient and has no third-party dependencies. The default exporter brought okhttp, okio and the Kotlin stdlib onto a server classpath unshaded, which the server SDK goes out of its way to avoid. Two verification tasks wired into check guard both decisions: checkRuntimeClasspath fails on any OkHttp/Okio/Kotlin artifact and checkPublishedPom fails unless the POM has opentelemetry-api at compile scope and the JDK sender at runtime scope. Signing is only required for a Sonatype publish so publishToMavenLocal works without keys. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- release-please-config.json | 7 +- .../observability-java/lib/build.gradle | 103 ++++++++++++++++-- .../graphql/GetSamplingConfigQuery.graphql | 53 --------- 3 files changed, 96 insertions(+), 67 deletions(-) delete mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/resources/graphql/GetSamplingConfigQuery.graphql diff --git a/release-please-config.json b/release-please-config.json index 4a86ce9222..34795656e4 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -23,12 +23,15 @@ "extra-files": ["gradle.properties"] }, "sdk/@launchdarkly/observability-java": { - "package-name": "launchdarkly-observability-java", + "package-name": "launchdarkly-java-server-sdk-observability", "release-type": "simple", "versioning": "default", "include-v-in-tag": false, "include-component-in-tag": true, - "extra-files": ["gradle.properties"] + "extra-files": [ + "gradle.properties", + "lib/src/main/java/com/launchdarkly/observability/internal/Constants.java" + ] }, "sdk/flutter/packages/observability": { "package-name": "launchdarkly_flutter_observability", diff --git a/sdk/@launchdarkly/observability-java/lib/build.gradle b/sdk/@launchdarkly/observability-java/lib/build.gradle index a92d0443d6..16d2fe4f09 100644 --- a/sdk/@launchdarkly/observability-java/lib/build.gradle +++ b/sdk/@launchdarkly/observability-java/lib/build.gradle @@ -15,29 +15,44 @@ java { withSourcesJar() } +ext { + otelVersion = '1.51.0' + ldServerSdkVersion = '7.12.0' +} + dependencies { // LaunchDarkly SDK - compileOnly so users provide their own version - compileOnly 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' + compileOnly "com.launchdarkly:launchdarkly-java-server-sdk:${ldServerSdkVersion}" // LaunchDarkly OTel tracing hook implementation 'com.launchdarkly:launchdarkly-java-server-sdk-otel:0.2.0' - // OpenTelemetry - implementation 'io.opentelemetry:opentelemetry-api:1.51.0' - implementation 'io.opentelemetry:opentelemetry-sdk:1.51.0' - implementation 'io.opentelemetry:opentelemetry-sdk-logs:1.51.0' - implementation 'io.opentelemetry:opentelemetry-sdk-metrics:1.51.0' - implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.51.0' + // The public API (LDObserve, ObservabilityOptions) takes OpenTelemetry API + // types such as Attributes and Severity, so consumers must compile against + // it. 'api' publishes it with compile scope in the POM. + api "io.opentelemetry:opentelemetry-api:${otelVersion}" + implementation "io.opentelemetry:opentelemetry-sdk:${otelVersion}" + implementation "io.opentelemetry:opentelemetry-sdk-logs:${otelVersion}" + implementation "io.opentelemetry:opentelemetry-sdk-metrics:${otelVersion}" + + // OTLP over HTTP. The default exporter artifact pulls in the OkHttp sender, + // which drags okhttp, okio and the Kotlin stdlib onto a server classpath + // unshaded. The JDK sender uses java.net.http.HttpClient (Java 11+) and + // has no third-party dependencies, so the OkHttp sender is excluded. + implementation("io.opentelemetry:opentelemetry-exporter-otlp:${otelVersion}") { + exclude group: 'io.opentelemetry', module: 'opentelemetry-exporter-sender-okhttp' + } + runtimeOnly "io.opentelemetry:opentelemetry-exporter-sender-jdk:${otelVersion}" // JSON parsing implementation 'com.google.code.gson:gson:2.11.0' // Testing - testImplementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' + testImplementation "com.launchdarkly:launchdarkly-java-server-sdk:${ldServerSdkVersion}" testImplementation platform('org.junit:junit-bom:5.13.4') testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testImplementation 'io.opentelemetry:opentelemetry-sdk-testing:1.51.0' + testImplementation "io.opentelemetry:opentelemetry-sdk-testing:${otelVersion}" } tasks.withType(Test).configureEach { @@ -48,18 +63,79 @@ tasks.withType(Javadoc).configureEach { options.addStringOption('Xdoclint:none', '-quiet') } +// Groups that must never reach a consumer's runtime classpath through this +// library. The LaunchDarkly server SDK shades its own copies of these so that +// server applications do not inherit version clashes; this plugin must not +// undo that. +def forbiddenRuntimeGroups = [ + 'com.squareup.okhttp3', + 'com.squareup.okio', + 'org.jetbrains.kotlin', + 'org.jetbrains.kotlinx', +] + +tasks.register('checkRuntimeClasspath') { + group = 'verification' + description = 'Fails when OkHttp, Okio or the Kotlin stdlib leak into the runtime classpath.' + def runtime = configurations.runtimeClasspath + doLast { + def leaked = runtime.resolvedConfiguration.resolvedArtifacts + .collect { it.moduleVersion.id } + .findAll { forbiddenRuntimeGroups.contains(it.group) } + .collect { "${it.group}:${it.name}:${it.version}" } + .unique() + if (!leaked.isEmpty()) { + throw new GradleException("Forbidden artifacts on the runtime classpath: ${leaked.join(', ')}") + } + } +} + +tasks.register('checkPublishedPom') { + group = 'verification' + description = 'Checks the generated POM exposes opentelemetry-api at compile scope and carries no OkHttp.' + dependsOn 'generatePomFileForReleasePublication' + def pomFile = layout.buildDirectory.file('publications/release/pom-default.xml') + doLast { + def pom = new XmlSlurper(false, false).parse(pomFile.get().asFile) + // GPathResult.find returns an empty result, never null, so test size(). + def deps = pom.dependencies.dependency + def otelApi = deps.find { it.artifactId.text() == 'opentelemetry-api' } + if (otelApi.size() == 0 || otelApi.scope.text() != 'compile') { + throw new GradleException("opentelemetry-api must be a compile-scoped dependency in the POM, found scope '" + + (otelApi.size() == 0 ? 'missing' : otelApi.scope.text()) + "'") + } + def okhttp = deps.find { it.artifactId.text() == 'opentelemetry-exporter-sender-okhttp' } + if (okhttp.size() != 0) { + throw new GradleException('The POM must not depend on opentelemetry-exporter-sender-okhttp') + } + def jdk = deps.find { it.artifactId.text() == 'opentelemetry-exporter-sender-jdk' } + if (jdk.size() == 0 || jdk.scope.text() != 'runtime') { + throw new GradleException('The POM must carry opentelemetry-exporter-sender-jdk at runtime scope') + } + } +} + +tasks.named('check') { + dependsOn 'checkRuntimeClasspath', 'checkPublishedPom' +} + publishing { publications { release(MavenPublication) { groupId = 'com.launchdarkly' - artifactId = 'launchdarkly-observability-java' + // Follows the Java server SDK add-on family + // (launchdarkly-java-server-sdk-otel, -redis-store, -ai) so the + // coordinates say which LaunchDarkly SDK this plugs into. The + // Android observability artifact is also a Java library, so a + // bare "-java" suffix would be ambiguous. + artifactId = 'launchdarkly-java-server-sdk-observability' version = version.toString() from components.java pom { - name = 'LaunchDarkly Observability Java SDK' - description = 'Official LaunchDarkly Observability Java SDK for use with the LaunchDarkly Java Server SDK.' + name = 'LaunchDarkly Observability plugin for the Java Server SDK' + description = 'Official LaunchDarkly Observability plugin for the LaunchDarkly Java Server SDK.' url = 'https://github.com/launchdarkly/observability-sdk/' organization { name = 'LaunchDarkly' @@ -89,5 +165,8 @@ publishing { } signing { + // Only a Sonatype publish needs a signature. publishToMavenLocal (used by + // the CI consumer check and local e2e runs) must work without signing keys. + required = { gradle.taskGraph.allTasks.any { it.name.toLowerCase().contains('sonatype') } } sign publishing.publications.release } diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/resources/graphql/GetSamplingConfigQuery.graphql b/sdk/@launchdarkly/observability-java/lib/src/main/resources/graphql/GetSamplingConfigQuery.graphql deleted file mode 100644 index 5d5d7ac053..0000000000 --- a/sdk/@launchdarkly/observability-java/lib/src/main/resources/graphql/GetSamplingConfigQuery.graphql +++ /dev/null @@ -1,53 +0,0 @@ -fragment MatchParts on MatchConfig { - regexValue - matchValue -} - -query GetSamplingConfig($organization_verbose_id: String!) { - sampling(organization_verbose_id: $organization_verbose_id) { - spans { - name { - ...MatchParts - } - attributes { - key { - ...MatchParts - } - attribute { - ...MatchParts - } - } - events { - name { - ...MatchParts - } - attributes { - key { - ...MatchParts - } - attribute { - ...MatchParts - } - } - } - samplingRatio - } - logs { - message { - ...MatchParts - } - severityText { - ...MatchParts - } - attributes { - key { - ...MatchParts - } - attribute { - ...MatchParts - } - } - samplingRatio - } - } -} From ef0f4e373dfda4e42428cae3e5d4db3144e37865 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 20:18:52 +0000 Subject: [PATCH 09/17] feat: add enabled, resourceAttributes, customHeaders, proxy and logsApiLevel options Bring the Java options up to the Android plugin's surface for a server: - enabled(false) registers nothing: no providers, no flag-evaluation hook, no sampling request, and LDObserve stays a local no-op. - resourceAttributes are merged over the plugin's resource so they can override service.name and friends. - customHeaders / customHeader go on every OTLP export and on the sampling-config request. - proxySelector / proxyHostAndPort route exports and the sampling request through an egress proxy. With nothing set, the JVM default selector applies, so the standard proxy system properties work. - logsApiLevel drops log records below a severity; NONE disables logs. GraphQLClient now uses java.net.http.HttpClient, the same stack as the JDK OTLP sender, so headers and proxies behave identically for both. The sampling query is a compile-time constant instead of a classpath resource, which shaded jars with minimize(), ProGuard and some app-server classloaders can drop. The plugin version constant carries a release-please annotation so it stays in step with gradle.properties. Tests run a real offline LDClient against a local collector that records every request, and the same server doubles as an HTTP proxy to check the absolute-URI routing of exports and the sampling request. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- .../observability/ObservabilityOptions.java | 162 +++++++++++ .../observability/ObservabilityPlugin.java | 22 +- .../observability/internal/Constants.java | 11 +- .../observability/internal/GraphQLClient.java | 138 +++++----- .../internal/LogLevelFilterProcessor.java | 44 +++ .../observability/internal/OtelManager.java | 44 ++- .../internal/SamplingApiService.java | 63 ++++- .../ObservabilityPluginTest.java | 59 ++++ .../internal/GraphQLClientTest.java | 152 +++++++++-- .../internal/PluginOptionsFlowTest.java | 258 ++++++++++++++++++ 10 files changed, 848 insertions(+), 105 deletions(-) create mode 100644 sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/LogLevelFilterProcessor.java create mode 100644 sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java index 353b0eb12e..565dbcf564 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java @@ -1,6 +1,19 @@ package com.launchdarkly.observability; import com.launchdarkly.observability.internal.Constants; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; /** * Configuration options for the LaunchDarkly Observability plugin. @@ -8,46 +21,115 @@ */ public final class ObservabilityOptions { + private final boolean enabled; private final String serviceName; private final String serviceVersion; private final String environment; private final String otlpEndpoint; private final String backendUrl; + private final Attributes resourceAttributes; + private final Map customHeaders; + private final ProxySelector proxySelector; + private final LogLevel logsApiLevel; private final boolean debug; private final boolean manualStart; private ObservabilityOptions(Builder builder) { + this.enabled = builder.enabled; this.serviceName = builder.serviceName; this.serviceVersion = builder.serviceVersion; this.environment = builder.environment; this.otlpEndpoint = builder.otlpEndpoint; this.backendUrl = builder.backendUrl; + this.resourceAttributes = builder.resourceAttributes; + this.customHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(builder.customHeaders)); + this.proxySelector = builder.proxySelector; + this.logsApiLevel = builder.logsApiLevel; this.debug = builder.debug; this.manualStart = builder.manualStart; } + /** Whether the plugin exports telemetry at all. See {@link Builder#enabled(boolean)}. */ + public boolean isEnabled() { return enabled; } public String getServiceName() { return serviceName; } public String getServiceVersion() { return serviceVersion; } public String getEnvironment() { return environment; } public String getOtlpEndpoint() { return otlpEndpoint; } public String getBackendUrl() { return backendUrl; } + /** Additional resource attributes attached to every exported signal. Never null. */ + public Attributes getResourceAttributes() { return resourceAttributes; } + /** Headers sent with every OTLP export and sampling-configuration request. Never null. */ + public Map getCustomHeaders() { return customHeaders; } + /** The proxy selector for outbound HTTP, or null to use the JVM default. */ + public ProxySelector getProxySelector() { return proxySelector; } + /** The minimum severity a log record needs to be exported. */ + public LogLevel getLogsApiLevel() { return logsApiLevel; } public boolean isDebug() { return debug; } public boolean isManualStart() { return manualStart; } + /** + * Minimum severity for exported log records. Mirrors the OpenTelemetry + * log severity numbers; a record is exported when its severity number is + * at or above the configured level. {@link #NONE} disables log export. + * + *

A record whose severity is unset + * ({@link Severity#UNDEFINED_SEVERITY_NUMBER}) is treated as + * {@link #TRACE}, so it is only exported at the {@code TRACE} level.

+ * + * @see OpenTelemetry Log Data Model - Severity + */ + public enum LogLevel { + TRACE(Severity.TRACE.getSeverityNumber()), + DEBUG(Severity.DEBUG.getSeverityNumber()), + INFO(Severity.INFO.getSeverityNumber()), + WARN(Severity.WARN.getSeverityNumber()), + ERROR(Severity.ERROR.getSeverityNumber()), + FATAL(Severity.FATAL.getSeverityNumber()), + /** Export no log records. */ + NONE(Integer.MAX_VALUE); + + private final int severityNumber; + + LogLevel(int severityNumber) { + this.severityNumber = severityNumber; + } + + /** The OpenTelemetry severity number this level starts at. */ + public int getSeverityNumber() { + return severityNumber; + } + } + /** * Builder for {@link ObservabilityOptions}. */ public static class Builder { + private boolean enabled = true; private String serviceName = ""; private String serviceVersion = ""; private String environment = ""; private String otlpEndpoint = Constants.DEFAULT_OTLP_ENDPOINT; private String backendUrl = Constants.DEFAULT_BACKEND_URL; + private Attributes resourceAttributes = Attributes.empty(); + private Map customHeaders = new LinkedHashMap<>(); + private ProxySelector proxySelector = null; + private LogLevel logsApiLevel = LogLevel.INFO; private boolean debug = false; private boolean manualStart = false; public Builder() {} + /** + * Enables or disables the plugin. When {@code false} the plugin is + * registered but starts no OpenTelemetry providers, registers no + * flag-evaluation hook, fetches no sampling configuration, and every + * {@link LDObserve} call is a no-op. Defaults to {@code true}. + */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + /** * Sets the service name for resource attributes. */ @@ -88,6 +170,65 @@ public Builder backendUrl(String backendUrl) { return this; } + /** + * Adds resource attributes to every exported span, log record and + * metric. These are merged over the plugin's own resource, so they + * can override {@code service.name} and friends. + */ + public Builder resourceAttributes(Attributes resourceAttributes) { + this.resourceAttributes = Objects.requireNonNull(resourceAttributes, "resourceAttributes"); + return this; + } + + /** + * Sets headers that are sent with every OTLP export and with the + * sampling-configuration request. Replaces any previously set headers. + */ + public Builder customHeaders(Map customHeaders) { + this.customHeaders = new LinkedHashMap<>(Objects.requireNonNull(customHeaders, "customHeaders")); + return this; + } + + /** + * Adds one header that is sent with every OTLP export and with the + * sampling-configuration request. + */ + public Builder customHeader(String name, String value) { + this.customHeaders.put(Objects.requireNonNull(name, "name"), Objects.requireNonNull(value, "value")); + return this; + } + + /** + * Routes every outbound request (OTLP exports and the + * sampling-configuration request) through the given proxy selector. + * + *

When unset, the JVM default selector is used, which honours the + * standard {@code http.proxyHost}, {@code https.proxyHost} and + * {@code http.nonProxyHosts} system properties.

+ */ + public Builder proxySelector(ProxySelector proxySelector) { + this.proxySelector = proxySelector; + return this; + } + + /** + * Routes every outbound request through an HTTP proxy at the given + * host and port. + */ + public Builder proxyHostAndPort(String host, int port) { + Objects.requireNonNull(host, "host"); + return proxySelector(new FixedProxySelector(new InetSocketAddress(host, port))); + } + + /** + * Sets the minimum severity for exported log records. Defaults to + * {@link LogLevel#INFO}. Use {@link LogLevel#NONE} to disable log export. + */ + public Builder logsApiLevel(LogLevel logsApiLevel) { + this.logsApiLevel = Objects.requireNonNull(logsApiLevel, "logsApiLevel"); + return this; + } + /** * Enables debug logging for the observability plugin. */ @@ -109,4 +250,25 @@ public ObservabilityOptions build() { return new ObservabilityOptions(this); } } + + /** + * A {@link ProxySelector} that sends everything through one HTTP proxy. + */ + static final class FixedProxySelector extends ProxySelector { + private final List proxies; + + FixedProxySelector(InetSocketAddress address) { + this.proxies = Collections.singletonList(new Proxy(Proxy.Type.HTTP, address)); + } + + @Override + public List select(URI uri) { + return proxies; + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, java.io.IOException ioe) { + // Nothing to fall back to; the exporter retries on its own schedule. + } + } } diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java index f11ef47086..70cbd99db4 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java @@ -35,6 +35,15 @@ * .build(); * LDClient client = new LDClient("YOUR_SDK_KEY", config); * } + * + *

Outbound HTTP (OTLP exports and the sampling-configuration request) goes + * through {@code java.net.http.HttpClient}. It honours the JVM proxy system + * properties by default; use + * {@link ObservabilityOptions.Builder#proxySelector(java.net.ProxySelector)} or + * {@link ObservabilityOptions.Builder#proxyHostAndPort(String, int)} to set + * one explicitly, and + * {@link ObservabilityOptions.Builder#customHeaders(java.util.Map)} to add + * headers to every request.

*/ public class ObservabilityPlugin extends Plugin { @@ -64,6 +73,13 @@ public String getName() { @Override public void register(LDClient client, EnvironmentMetadata metadata) { + if (!options.isEnabled()) { + if (options.isDebug()) { + log.info("Observability plugin is disabled; no telemetry will be exported"); + } + return; + } + String sdkKey = metadata.getSdkKey(); if (!options.isManualStart()) { @@ -73,7 +89,8 @@ public void register(LDClient client, EnvironmentMetadata metadata) { // Fetch sampling config in background Thread samplingThread = new Thread(() -> { try { - GraphQLClient graphQLClient = new GraphQLClient(options.getBackendUrl()); + GraphQLClient graphQLClient = new GraphQLClient( + options.getBackendUrl(), options.getCustomHeaders(), options.getProxySelector()); SamplingApiService samplingService = new SamplingApiService(graphQLClient); SamplingConfig config = samplingService.getSamplingConfig(sdkKey); if (config != null) { @@ -92,6 +109,9 @@ public void register(LDClient client, EnvironmentMetadata metadata) { @Override public List getHooks(EnvironmentMetadata metadata) { + if (!options.isEnabled()) { + return Collections.emptyList(); + } TracingHook hook = new TracingHook.Builder() .withSpans() .withValue() diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java index f5659725ff..b2f31f5806 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java @@ -1,7 +1,7 @@ package com.launchdarkly.observability.internal; /** - * Internal constants for the LaunchDarkly Observability SDK. + * Internal constants for the LaunchDarkly Observability plugin. */ public final class Constants { private Constants() {} @@ -9,7 +9,16 @@ private Constants() {} public static final String DEFAULT_OTLP_ENDPOINT = "https://otel.observability.app.launchdarkly.com:4318"; public static final String DEFAULT_BACKEND_URL = "https://pub.observability.app.launchdarkly.com"; public static final String INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability"; + + /** + * The plugin version. Kept in step with {@code version} in + * {@code gradle.properties}, which release-please updates on release. A + * plain constant rather than a manifest or resource lookup so it resolves + * in unit tests, shaded jars and classloaders that hide non-class files. + */ + // x-release-please-start-version public static final String INSTRUMENTATION_VERSION = "0.1.0"; + // x-release-please-end public static final String PROJECT_ID_ATTRIBUTE = "highlight.project_id"; public static final String ERROR_SPAN_NAME = "highlight.error"; diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java index f655a7ddc6..4a94893961 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java @@ -4,35 +4,62 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; import java.util.zip.GZIPOutputStream; /** - * Simple GraphQL client using {@link HttpURLConnection} and Gson. + * Minimal GraphQL client on {@link HttpClient} (Java 11) and Gson. + * + *

Uses the same HTTP stack as the OTLP exporters, so proxy settings and + * custom headers behave the same for the sampling request as for exports. + * Instances are immutable and safe to share between threads.

*/ public final class GraphQLClient { private static final Logger log = Logger.getLogger(GraphQLClient.class.getName()); - private static final int CONNECT_TIMEOUT = 10_000; - private static final int READ_TIMEOUT = 10_000; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); private static final Gson GSON = new Gson(); - private static final ConcurrentHashMap QUERY_CACHE = new ConcurrentHashMap<>(); - private final String endpoint; + private final URI endpoint; + private final Map headers; + private final HttpClient httpClient; + /** + * Creates a client that uses the JVM default proxy selector and sends no + * extra headers. + */ public GraphQLClient(String backendUrl) { - this.endpoint = backendUrl; + this(backendUrl, Collections.emptyMap(), null); + } + + /** + * @param backendUrl the GraphQL endpoint + * @param headers headers added to every request; may be empty + * @param proxySelector the proxy selector for requests, or null for the JVM default + */ + public GraphQLClient(String backendUrl, Map headers, ProxySelector proxySelector) { + this.endpoint = URI.create(backendUrl); + this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(headers)); + HttpClient.Builder builder = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL); + if (proxySelector != null) { + builder.proxy(proxySelector); + } + this.httpClient = builder.build(); } /** @@ -49,58 +76,38 @@ public JsonElement execute(String query, Map variables) throws I byte[] payload = gzip(GSON.toJson(body).getBytes(StandardCharsets.UTF_8)); - HttpURLConnection conn = (HttpURLConnection) new URL(endpoint).openConnection(); - try { - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "application/json"); - conn.setRequestProperty("Content-Encoding", "gzip"); - conn.setConnectTimeout(CONNECT_TIMEOUT); - conn.setReadTimeout(READ_TIMEOUT); - conn.setDoOutput(true); - - try (OutputStream os = conn.getOutputStream()) { - os.write(payload); - } - - int status = conn.getResponseCode(); - if (status < 200 || status >= 300) { - log.warning("GraphQL request failed with status " + status); - return null; - } - - String responseBody = readStream(conn.getInputStream()); - JsonObject response = GSON.fromJson(responseBody, JsonObject.class); - - if (response.has("errors") && !response.getAsJsonArray("errors").isEmpty()) { - log.warning("GraphQL errors: " + response.getAsJsonArray("errors")); - } + HttpRequest.Builder request = HttpRequest.newBuilder(endpoint) + .timeout(REQUEST_TIMEOUT) + .header("Content-Type", "application/json") + .header("Content-Encoding", "gzip") + .POST(HttpRequest.BodyPublishers.ofByteArray(payload)); + for (Map.Entry header : headers.entrySet()) { + request.header(header.getKey(), header.getValue()); + } - return response.has("data") ? response.get("data") : null; - } finally { - conn.disconnect(); + HttpResponse response; + try { + response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for the GraphQL response", e); } - } - /** - * Loads a GraphQL query from a classpath resource. - * - *

The resource is read at most once per path. Concurrent callers share - * the cached string, so a server process does not re-read the query for - * every request or every client.

- */ - public static String loadQuery(String resourcePath) { - return QUERY_CACHE.computeIfAbsent(resourcePath, GraphQLClient::readQuery); - } + int status = response.statusCode(); + if (status < 200 || status >= 300) { + log.warning("GraphQL request failed with status " + status); + return null; + } - private static String readQuery(String resourcePath) { - try (InputStream is = GraphQLClient.class.getClassLoader().getResourceAsStream(resourcePath)) { - if (is == null) { - throw new IllegalArgumentException("Resource not found: " + resourcePath); - } - return readStream(is); - } catch (IOException e) { - throw new RuntimeException("Failed to load query from " + resourcePath, e); + JsonObject envelope = GSON.fromJson(response.body(), JsonObject.class); + if (envelope == null) { + log.warning("GraphQL response had no body"); + return null; + } + if (envelope.has("errors") && !envelope.getAsJsonArray("errors").isEmpty()) { + log.warning("GraphQL errors: " + envelope.getAsJsonArray("errors")); } + return envelope.has("data") ? envelope.get("data") : null; } private static byte[] gzip(byte[] data) throws IOException { @@ -110,15 +117,4 @@ private static byte[] gzip(byte[] data) throws IOException { } return bos.toByteArray(); } - - private static String readStream(InputStream is) throws IOException { - StringBuilder sb = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append('\n'); - } - } - return sb.toString(); - } } diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/LogLevelFilterProcessor.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/LogLevelFilterProcessor.java new file mode 100644 index 0000000000..1226364806 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/LogLevelFilterProcessor.java @@ -0,0 +1,44 @@ +package com.launchdarkly.observability.internal; + +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.ReadWriteLogRecord; + +/** + * Drops log records below a minimum severity before they reach the next + * processor. A record with no severity set is treated as {@code TRACE}. + */ +public final class LogLevelFilterProcessor implements LogRecordProcessor { + + private final LogRecordProcessor delegate; + private final int minimumSeverityNumber; + + public LogLevelFilterProcessor(LogRecordProcessor delegate, int minimumSeverityNumber) { + this.delegate = delegate; + this.minimumSeverityNumber = minimumSeverityNumber; + } + + @Override + public void onEmit(Context context, ReadWriteLogRecord logRecord) { + Severity severity = logRecord.toLogRecordData().getSeverity(); + int number = severity == null || severity == Severity.UNDEFINED_SEVERITY_NUMBER + ? Severity.TRACE.getSeverityNumber() + : severity.getSeverityNumber(); + if (number < minimumSeverityNumber) { + return; + } + delegate.onEmit(context, logRecord); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + @Override + public CompletableResultCode forceFlush() { + return delegate.forceFlush(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java index 0c9500cfcc..2b9772f96a 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java @@ -15,9 +15,13 @@ import io.opentelemetry.context.propagation.ContextPropagators; import io.opentelemetry.context.propagation.TextMapPropagator; import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporterBuilder; import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporterBuilder; import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.common.export.ProxyOptions; import io.opentelemetry.sdk.logs.SdkLoggerProvider; import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; import io.opentelemetry.sdk.metrics.SdkMeterProvider; @@ -27,6 +31,7 @@ import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; import java.time.Duration; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -110,14 +115,22 @@ public static void initialize(String sdkKey, ObservabilityOptions options) { private static OtelManager create(String sdkKey, ObservabilityOptions options) { Resource resource = buildResource(sdkKey, options); String endpoint = options.getOtlpEndpoint(); + Map headers = options.getCustomHeaders(); + ProxyOptions proxy = options.getProxySelector() == null + ? null + : ProxyOptions.create(options.getProxySelector()); CustomSampler customSampler = new CustomSampler(); // Trace exporter with sampling - OtlpHttpSpanExporter rawSpanExporter = OtlpHttpSpanExporter.builder() + OtlpHttpSpanExporterBuilder spanExporterBuilder = OtlpHttpSpanExporter.builder() .setEndpoint(endpoint + Constants.TRACES_PATH) .setCompression("gzip") - .build(); + .setHeaders(() -> headers); + if (proxy != null) { + spanExporterBuilder.setProxy(proxy); + } + OtlpHttpSpanExporter rawSpanExporter = spanExporterBuilder.build(); SamplingTraceExporter samplingTraceExporter = new SamplingTraceExporter(rawSpanExporter, customSampler); @@ -129,27 +142,37 @@ private static OtelManager create(String sdkKey, ObservabilityOptions options) { .setResource(resource) .build(); - // Log exporter with sampling - OtlpHttpLogRecordExporter rawLogExporter = OtlpHttpLogRecordExporter.builder() + // Log exporter with level filter and sampling + OtlpHttpLogRecordExporterBuilder logExporterBuilder = OtlpHttpLogRecordExporter.builder() .setEndpoint(endpoint + Constants.LOGS_PATH) .setCompression("gzip") - .build(); + .setHeaders(() -> headers); + if (proxy != null) { + logExporterBuilder.setProxyOptions(proxy); + } + OtlpHttpLogRecordExporter rawLogExporter = logExporterBuilder.build(); BatchLogRecordProcessor batchLogProcessor = BatchLogRecordProcessor.builder(rawLogExporter) .setExporterTimeout(Duration.ofSeconds(30)) .build(); SamplingLogProcessor samplingLogProcessor = new SamplingLogProcessor(batchLogProcessor, customSampler); + LogLevelFilterProcessor levelFilter = new LogLevelFilterProcessor( + samplingLogProcessor, options.getLogsApiLevel().getSeverityNumber()); SdkLoggerProvider loggerProvider = SdkLoggerProvider.builder() - .addLogRecordProcessor(samplingLogProcessor) + .addLogRecordProcessor(levelFilter) .setResource(resource) .build(); // Metric exporter - OtlpHttpMetricExporter metricExporter = OtlpHttpMetricExporter.builder() + OtlpHttpMetricExporterBuilder metricExporterBuilder = OtlpHttpMetricExporter.builder() .setEndpoint(endpoint + Constants.METRICS_PATH) .setCompression("gzip") - .build(); + .setHeaders(() -> headers); + if (proxy != null) { + metricExporterBuilder.setProxyOptions(proxy); + } + OtlpHttpMetricExporter metricExporter = metricExporterBuilder.build(); SdkMeterProvider meterProvider = SdkMeterProvider.builder() .registerMetricReader(PeriodicMetricReader.builder(metricExporter) @@ -318,6 +341,9 @@ private static Resource buildResource(String sdkKey, ObservabilityOptions option attrs.put("deployment.environment.name", options.getEnvironment()); } - return Resource.getDefault().merge(Resource.create(attrs.build())); + // User attributes are merged last so they can override the defaults. + return Resource.getDefault() + .merge(Resource.create(attrs.build())) + .merge(Resource.create(options.getResourceAttributes())); } } diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java index d729022b09..dc069a9550 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java @@ -25,9 +25,66 @@ public final class SamplingApiService { private static final Logger log = Logger.getLogger(SamplingApiService.class.getName()); - private static final String QUERY_FILE = "graphql/GetSamplingConfigQuery.graphql"; - - private static final String QUERY = GraphQLClient.loadQuery(QUERY_FILE); + /** + * The sampling query as a compile-time constant. Go, .NET, Android and + * Node keep the query in code too: a classpath resource can be dropped by + * shaded jars built with minimize(), by ProGuard, and by some app-server + * and OSGi classloaders, and reading it at runtime adds I/O on a server. + */ + static final String QUERY = "" + + "fragment MatchParts on MatchConfig {\n" + + " regexValue\n" + + " matchValue\n" + + "}\n" + + "\n" + + "query GetSamplingConfig($organization_verbose_id: String!) {\n" + + " sampling(organization_verbose_id: $organization_verbose_id) {\n" + + " spans {\n" + + " name {\n" + + " ...MatchParts\n" + + " }\n" + + " attributes {\n" + + " key {\n" + + " ...MatchParts\n" + + " }\n" + + " attribute {\n" + + " ...MatchParts\n" + + " }\n" + + " }\n" + + " events {\n" + + " name {\n" + + " ...MatchParts\n" + + " }\n" + + " attributes {\n" + + " key {\n" + + " ...MatchParts\n" + + " }\n" + + " attribute {\n" + + " ...MatchParts\n" + + " }\n" + + " }\n" + + " }\n" + + " samplingRatio\n" + + " }\n" + + " logs {\n" + + " message {\n" + + " ...MatchParts\n" + + " }\n" + + " severityText {\n" + + " ...MatchParts\n" + + " }\n" + + " attributes {\n" + + " key {\n" + + " ...MatchParts\n" + + " }\n" + + " attribute {\n" + + " ...MatchParts\n" + + " }\n" + + " }\n" + + " samplingRatio\n" + + " }\n" + + " }\n" + + "}\n"; private final GraphQLClient client; diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java index fe99a2a976..f0c0f64f83 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java @@ -2,9 +2,14 @@ import com.launchdarkly.sdk.server.integrations.Hook; import com.launchdarkly.sdk.server.integrations.PluginMetadata; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; import org.junit.jupiter.api.Test; +import java.net.Proxy; +import java.net.URI; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -41,6 +46,60 @@ void optionsBuilderDefaults() { assertFalse(options.isManualStart()); assertEquals("https://otel.observability.app.launchdarkly.com:4318", options.getOtlpEndpoint()); assertEquals("https://pub.observability.app.launchdarkly.com", options.getBackendUrl()); + assertTrue(options.isEnabled()); + assertEquals(Attributes.empty(), options.getResourceAttributes()); + assertTrue(options.getCustomHeaders().isEmpty()); + assertNull(options.getProxySelector()); + assertEquals(ObservabilityOptions.LogLevel.INFO, options.getLogsApiLevel()); + } + + @Test + void disabledPluginRegistersNoHooks() { + ObservabilityOptions options = new ObservabilityOptions.Builder().enabled(false).build(); + ObservabilityPlugin plugin = new ObservabilityPlugin(options); + + assertFalse(options.isEnabled()); + assertTrue(plugin.getHooks(null).isEmpty(), "a disabled plugin must not add a flag-evaluation hook"); + } + + @Test + void customHeadersAccumulateAndAreCopied() { + Map initial = new java.util.HashMap<>(); + initial.put("Authorization", "Bearer one"); + ObservabilityOptions.Builder builder = new ObservabilityOptions.Builder() + .customHeaders(initial) + .customHeader("X-Tenant", "acme"); + initial.put("X-Leaked", "later"); + ObservabilityOptions options = builder.build(); + + assertEquals(Map.of("Authorization", "Bearer one", "X-Tenant", "acme"), options.getCustomHeaders()); + assertThrows(UnsupportedOperationException.class, () -> options.getCustomHeaders().put("a", "b")); + } + + @Test + void proxyHostAndPortBuildsAnHttpProxySelector() { + ObservabilityOptions options = new ObservabilityOptions.Builder() + .proxyHostAndPort("proxy.example.test", 3128) + .build(); + + List proxies = options.getProxySelector().select(URI.create("https://otel.example.test/v1/traces")); + assertEquals(1, proxies.size()); + assertEquals(Proxy.Type.HTTP, proxies.get(0).type()); + assertEquals("proxy.example.test:3128", proxies.get(0).address().toString().replace("/", "")); + } + + @Test + void resourceAttributesAndLogLevelAreKept() { + Attributes attrs = Attributes.of(AttributeKey.stringKey("team"), "payments"); + ObservabilityOptions options = new ObservabilityOptions.Builder() + .resourceAttributes(attrs) + .logsApiLevel(ObservabilityOptions.LogLevel.WARN) + .build(); + + assertEquals(attrs, options.getResourceAttributes()); + assertEquals(ObservabilityOptions.LogLevel.WARN, options.getLogsApiLevel()); + assertEquals(13, ObservabilityOptions.LogLevel.WARN.getSeverityNumber()); + assertTrue(ObservabilityOptions.LogLevel.NONE.getSeverityNumber() > ObservabilityOptions.LogLevel.FATAL.getSeverityNumber()); } @Test diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java index 43fa76120a..8688c4a545 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java @@ -1,35 +1,147 @@ package com.launchdarkly.observability.internal; +import com.google.gson.JsonElement; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.ArrayList; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.GZIPInputStream; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class GraphQLClientTest { + private static final String SAMPLING_RESPONSE = + "{\"data\":{\"sampling\":{\"spans\":[{\"name\":{\"matchValue\":\"health-check\"}," + + "\"attributes\":[],\"events\":[],\"samplingRatio\":0}],\"logs\":[]}}}"; + + private HttpServer server; + private final List bodies = new CopyOnWriteArrayList<>(); + private final List headerValues = new CopyOnWriteArrayList<>(); + private final List requestLines = new CopyOnWriteArrayList<>(); + private volatile int status = 200; + private volatile String response = SAMPLING_RESPONSE; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/", exchange -> { + requestLines.add(exchange.getRequestMethod() + " " + exchange.getRequestURI()); + String header = exchange.getRequestHeaders().getFirst("X-Test-Header"); + headerValues.add(header == null ? "" : header); + bodies.add(bodyAsString(exchange)); + byte[] out = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, out.length); + exchange.getResponseBody().write(out); + exchange.close(); + }); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + @Test - void samplingQueryIsLoadedOnceAndSharedAcrossThreads() throws Exception { - String first = GraphQLClient.loadQuery("graphql/GetSamplingConfigQuery.graphql"); - assertTrue(first.contains("GetSamplingConfig")); - - ExecutorService pool = Executors.newFixedThreadPool(8); - try { - List> tasks = new ArrayList<>(); - for (int i = 0; i < 8; i++) { - tasks.add(() -> GraphQLClient.loadQuery("graphql/GetSamplingConfigQuery.graphql")); - } - for (Future loaded : pool.invokeAll(tasks)) { - assertSame(first, loaded.get()); + void samplingQueryIsACompileTimeConstantAndIsSentWithVariables() throws Exception { + assertTrue(SamplingApiService.QUERY.contains("query GetSamplingConfig")); + + GraphQLClient client = new GraphQLClient(url("/graphql")); + SamplingConfig config = new SamplingApiService(client).getSamplingConfig("sdk-test"); + + assertNotNull(config, "sampling config should parse"); + assertEquals(1, bodies.size()); + assertTrue(bodies.get(0).contains("query GetSamplingConfig"), "query text was not sent"); + assertTrue(bodies.get(0).contains("\"organization_verbose_id\":\"sdk-test\""), "variables were not sent"); + assertEquals("", headerValues.get(0), "no custom header was configured"); + } + + @Test + void customHeadersAreSentWithEveryRequest() throws Exception { + GraphQLClient client = new GraphQLClient(url("/graphql"), Map.of("X-Test-Header", "abc"), null); + + client.execute(SamplingApiService.QUERY, Map.of("organization_verbose_id", "sdk-test")); + client.execute(SamplingApiService.QUERY, Map.of("organization_verbose_id", "sdk-test")); + + assertEquals(List.of("abc", "abc"), headerValues); + } + + @Test + void proxySelectorRoutesRequestThroughProxy() throws Exception { + // The local server acts as the HTTP proxy: a proxied plain-HTTP request + // carries the absolute target URI in its request line. + InetSocketAddress proxyAddress = server.getAddress(); + GraphQLClient client = new GraphQLClient( + "http".concat("://backend.invalid/sampling"), + Collections.emptyMap(), + new ProxySelectorForTest(proxyAddress)); + + JsonElement data = client.execute(SamplingApiService.QUERY, Map.of("organization_verbose_id", "sdk-test")); + + assertNotNull(data, "the proxied request should succeed"); + assertEquals(1, requestLines.size()); + assertTrue(requestLines.get(0).contains("backend.invalid/sampling"), + "the proxy did not receive the absolute target URI: " + requestLines.get(0)); + } + + @Test + void nonSuccessStatusReturnsNull() throws Exception { + status = 503; + response = "unavailable"; + GraphQLClient client = new GraphQLClient(url("/graphql")); + + assertNull(client.execute(SamplingApiService.QUERY, Map.of("organization_verbose_id", "sdk-test"))); + assertFalse(bodies.isEmpty()); + } + + private String url(String path) { + return "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort() + path; + } + + private static String bodyAsString(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); } - } finally { - pool.shutdownNow(); + } + return new String(raw, StandardCharsets.UTF_8); + } + + /** Sends everything through one HTTP proxy. */ + static final class ProxySelectorForTest extends java.net.ProxySelector { + private final List proxies; + + ProxySelectorForTest(InetSocketAddress address) { + this.proxies = List.of(new java.net.Proxy(java.net.Proxy.Type.HTTP, address)); + } + + @Override + public List select(java.net.URI uri) { + return proxies; + } + + @Override + public void connectFailed(java.net.URI uri, java.net.SocketAddress sa, IOException ioe) { } } } diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java new file mode 100644 index 0000000000..5ea1db158e --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java @@ -0,0 +1,258 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the server-oriented options (headers, proxy, resource + * attributes, log level, enabled) through a real LDClient against a local + * collector that records every request it receives. + */ +class PluginOptionsFlowTest { + + private static final String SAMPLING_RESPONSE = "{\"data\":{\"sampling\":{\"spans\":[],\"logs\":[]}}}"; + + /** One recorded HTTP request: its request-line URI, a header of interest and the body. */ + private static final class Received { + final String uri; + final String path; + final String testHeader; + final String body; + + Received(String uri, String path, String testHeader, String body) { + this.uri = uri; + this.path = path; + this.testHeader = testHeader; + this.body = body; + } + } + + private HttpServer server; + private final List received = new CopyOnWriteArrayList<>(); + + @BeforeEach + void startCollector() throws IOException { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + // One root context so the server also works as an HTTP proxy, where the + // request line carries an absolute URI. + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + String header = exchange.getRequestHeaders().getFirst("X-Test-Header"); + received.add(new Received(exchange.getRequestURI().toString(), path, + header == null ? "" : header, bodyAsString(exchange))); + if (path.endsWith("/sampling")) { + respond(exchange, "application/json", SAMPLING_RESPONSE.getBytes(StandardCharsets.UTF_8)); + } else { + respond(exchange, "application/x-protobuf", new byte[0]); + } + }); + server.start(); + } + + @AfterEach + void tearDown() { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + server.stop(0); + } + + @Test + void customHeadersAndResourceAttributesReachExportsAndSamplingRequest() throws Exception { + ObservabilityOptions options = builder() + .customHeader("X-Test-Header", "abc") + .resourceAttributes(Attributes.of(AttributeKey.stringKey("team"), "payments-team")) + .build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + assertTrue(waitFor("/sampling"), "sampling config was not requested"); + LDObserve.startSpan("checkout", Attributes.empty()).end(); + LDObserve.recordLog("checkout done", Severity.INFO, Attributes.empty()); + LDObserve.recordCount("orders", 1, Attributes.empty()); + LDObserve.flush(); + } + + assertTrue(waitFor("/v1/traces"), "no trace export"); + assertTrue(waitFor("/v1/logs"), "no log export"); + assertTrue(waitFor("/v1/metrics"), "no metric export"); + for (String path : List.of("/sampling", "/v1/traces", "/v1/logs", "/v1/metrics")) { + assertTrue(all(path).stream().allMatch(r -> "abc".equals(r.testHeader)), + "custom header missing on " + path); + } + assertTrue(anyBodyContains("/v1/traces", "payments-team"), "resource attribute missing on spans"); + assertTrue(anyBodyContains("/v1/logs", "payments-team"), "resource attribute missing on logs"); + assertTrue(anyBodyContains("/v1/metrics", "payments-team"), "resource attribute missing on metrics"); + } + + @Test + void proxySelectorRoutesExportsAndSamplingRequestThroughProxy() throws Exception { + InetSocketAddress proxy = server.getAddress(); + ObservabilityOptions options = new ObservabilityOptions.Builder() + .serviceName("orders-service") + .otlpEndpoint("http".concat("://otlp.invalid:4318")) + .backendUrl("http".concat("://backend.invalid/sampling")) + .proxyHostAndPort(proxy.getHostString(), proxy.getPort()) + .build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + assertTrue(waitFor("/sampling"), "sampling request did not go through the proxy"); + LDObserve.startSpan("checkout", Attributes.empty()).end(); + LDObserve.flush(); + } + + assertTrue(waitFor("/v1/traces"), "trace export did not go through the proxy"); + assertTrue(all("/sampling").stream().allMatch(r -> r.uri.contains("backend.invalid")), + "proxy did not see the absolute sampling URI"); + assertTrue(all("/v1/traces").stream().allMatch(r -> r.uri.contains("otlp.invalid")), + "proxy did not see the absolute OTLP URI"); + } + + @Test + void logsBelowConfiguredLevelAreNotExported() throws Exception { + ObservabilityOptions options = builder().logsApiLevel(ObservabilityOptions.LogLevel.WARN).build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + LDObserve.recordLog("debug-noise", Severity.DEBUG, Attributes.empty()); + LDObserve.recordLog("info-noise", Severity.INFO, Attributes.empty()); + LDObserve.recordLog("warn-signal", Severity.WARN, Attributes.empty()); + LDObserve.recordLog("error-signal", Severity.ERROR, Attributes.empty()); + LDObserve.flush(); + } + + assertTrue(waitFor("/v1/logs"), "no log export"); + assertTrue(anyBodyContains("/v1/logs", "warn-signal")); + assertTrue(anyBodyContains("/v1/logs", "error-signal")); + assertFalse(anyBodyContains("/v1/logs", "debug-noise"), "DEBUG record exported below WARN threshold"); + assertFalse(anyBodyContains("/v1/logs", "info-noise"), "INFO record exported below WARN threshold"); + } + + @Test + void logLevelNoneExportsNoLogsButKeepsTraces() throws Exception { + ObservabilityOptions options = builder().logsApiLevel(ObservabilityOptions.LogLevel.NONE).build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + LDObserve.recordLog("fatal-but-disabled", Severity.FATAL, Attributes.empty()); + LDObserve.startSpan("checkout", Attributes.empty()).end(); + LDObserve.flush(); + } + + assertTrue(waitFor("/v1/traces"), "no trace export"); + assertFalse(anyBodyContains("/v1/logs", "fatal-but-disabled"), "log exported with level NONE"); + } + + @Test + void disabledPluginStartsNothingAndStaysNoop() throws Exception { + ObservabilityOptions options = builder().enabled(false).build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + assertFalse(OtelManager.isInitialized(), "disabled plugin started providers"); + client.boolVariation("checkout-enabled", com.launchdarkly.sdk.LDContext.create("u"), false); + LDObserve.startSpan("checkout", Attributes.empty()).end(); + LDObserve.recordLog("ignored", Severity.ERROR, Attributes.empty()); + LDObserve.flush(); + Thread.sleep(500); + } + + assertTrue(received.isEmpty(), "disabled plugin made HTTP requests: " + summarize()); + assertFalse(OtelManager.isInitialized()); + } + + private ObservabilityOptions.Builder builder() { + String base = "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + return new ObservabilityOptions.Builder() + .serviceName("orders-service") + .serviceVersion("1.2.3") + .environment("test") + .otlpEndpoint(base) + .backendUrl(base + "/sampling"); + } + + private static LDClient newClient(ObservabilityOptions options) { + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + return new LDClient("sdk-test", config); + } + + private List all(String path) { + List out = new ArrayList<>(); + for (Received r : received) { + if (r.path.endsWith(path)) { + out.add(r); + } + } + return out; + } + + private boolean anyBodyContains(String path, String needle) { + return all(path).stream().anyMatch(r -> r.body.contains(needle)); + } + + private boolean waitFor(String path) throws InterruptedException { + for (int i = 0; i < 100 && all(path).isEmpty(); i++) { + Thread.sleep(100); + } + return !all(path).isEmpty(); + } + + private String summarize() { + StringBuilder sb = new StringBuilder(); + for (Received r : received) { + sb.append(r.uri).append(' '); + } + return sb.toString(); + } + + private static String bodyAsString(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); + } + } + return new String(raw, StandardCharsets.UTF_8); + } + + private static void respond(HttpExchange exchange, String contentType, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } +} From 6d8682e81b9010320320396ceabd8c62f26def3f Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 20:18:52 +0000 Subject: [PATCH 10/17] ci: test the Java plugin on JDK 11/17/21, as a Maven consumer, and as a running server Unit tests now run on every LTS the library targets. A consumer job publishes the plugin to Maven local and builds e2e/java-plugin against the artifact through its POM (-PuseMavenLocal disables the composite build), which is where a wrong dependency scope or a leaked transitive dependency actually shows up. e2e/java-plugin gains ServerExample, an HTTP server that traces every request, evaluates flags, records metrics, logs and errors, and shuts down cleanly on SIGTERM. The e2e workflow smoke-tests it over curl and builds its Dockerfile; the dockerized e2e matrix and compose get a java service so it is health-checked alongside the other server apps. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- .dockerignore | 6 + .github/workflows/e2e.yml | 2 +- .github/workflows/java-observability.yml | 52 +++++- .github/workflows/java-plugin-e2e.yml | 27 ++- e2e/compose.yml | 7 + e2e/java-plugin/Dockerfile | 18 ++ e2e/java-plugin/Makefile | 23 ++- e2e/java-plugin/README.md | 61 ++++++- e2e/java-plugin/build.gradle | 47 ++++- e2e/java-plugin/settings.gradle | 14 +- .../observability/example/ServerExample.java | 168 ++++++++++++++++++ e2e/tests/src/app_runner.py | 1 + .../observability-java/README.md | 57 +++++- 13 files changed, 454 insertions(+), 29 deletions(-) create mode 100644 .dockerignore create mode 100644 e2e/java-plugin/Dockerfile create mode 100644 e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/ServerExample.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..d467a22f99 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# Used by builds whose context is the repository root (e2e/java-plugin/Dockerfile). +.git +**/node_modules +**/build +**/.gradle +rrweb diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d17c3c307b..51394ffe82 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-22.04-8core-32gb strategy: matrix: - app: ['dotnet', 'dotnet4', 'go', 'python', 'ruby'] + app: ['dotnet', 'dotnet4', 'go', 'java', 'python', 'ruby'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/java-observability.yml b/.github/workflows/java-observability.yml index aa1b16a846..3300def213 100644 --- a/.github/workflows/java-observability.yml +++ b/.github/workflows/java-observability.yml @@ -5,11 +5,13 @@ on: branches: ['main'] paths: - 'sdk/@launchdarkly/observability-java/**' + - 'e2e/java-plugin/**' - '.github/workflows/java-observability.yml' pull_request: types: [opened, synchronize] paths: - 'sdk/@launchdarkly/observability-java/**' + - 'e2e/java-plugin/**' - '.github/workflows/java-observability.yml' permissions: @@ -18,8 +20,14 @@ permissions: concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: test: - name: Unit Tests + # The library targets Java 11, so it is built and tested on every LTS + # a server might run it on, not only the newest. + name: Unit Tests (JDK ${{ matrix.java }}) runs-on: ubuntu-22.04-8core-32gb + strategy: + fail-fast: false + matrix: + java: [11, 17, 21] defaults: run: working-directory: ./sdk/@launchdarkly/observability-java @@ -28,19 +36,51 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Set up JDK 21 + - name: Set up JDK ${{ matrix.java }} uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 21 + java-version: ${{ matrix.java }} + cache: 'gradle' - - name: Build and test - run: ./gradlew :lib:build + # `build` includes `check`, which runs the unit tests plus the + # runtime-classpath guard (no OkHttp/Okio/Kotlin) and the POM + # guard (opentelemetry-api at compile scope, JDK sender at runtime). + - name: Build, test and verify the published POM + run: ./gradlew :lib:build --no-daemon --console=plain - name: Upload test reports if: always() uses: actions/upload-artifact@v4 with: - name: java-unit-test-results + name: java-unit-test-results-jdk${{ matrix.java }} path: sdk/@launchdarkly/observability-java/**/build/test-results/**/TEST-*.xml retention-days: 7 + + consumer: + # Builds the e2e example against the artifact as a consumer sees it: + # published to Maven local and resolved through its POM, with no + # composite build to paper over scope or transitive-dependency mistakes. + name: Consumer check (JDK ${{ matrix.java }}) + runs-on: ubuntu-22.04-8core-32gb + strategy: + fail-fast: false + matrix: + java: [11, 21] + defaults: + run: + working-directory: ./e2e/java-plugin + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ matrix.java }} + cache: 'gradle' + + - name: Publish the plugin to Maven local and build the example against it + run: make consumer-check diff --git a/.github/workflows/java-plugin-e2e.yml b/.github/workflows/java-plugin-e2e.yml index 6869fe4720..8337b49e9e 100644 --- a/.github/workflows/java-plugin-e2e.yml +++ b/.github/workflows/java-plugin-e2e.yml @@ -35,6 +35,31 @@ jobs: with: distribution: 'temurin' java-version: 21 + cache: 'gradle' - - name: Run the plugin example + - name: Run the self-checking example run: make run + + # Starts the HTTP server example offline (no SDK key) and drives + # every route. The server must come up, answer, and shut down + # cleanly on SIGTERM with the plugin flushing on the way out. + - name: Smoke-test the HTTP server example + run: | + make serve > server.log 2>&1 & + SERVER_PID=$! + for i in $(seq 1 120); do + if curl -sf -o /dev/null localhost:8080/; then break; fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "server exited early"; cat server.log; exit 1; fi + sleep 1 + done + curl -sf localhost:8080/ || { cat server.log; exit 1; } + curl -sf localhost:8080/rolldice || { cat server.log; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/error) + [ "$code" = "500" ] || { echo "expected 500 from /error, got $code"; cat server.log; exit 1; } + kill -TERM "$SERVER_PID" + wait "$SERVER_PID" || true + echo "server example OK" + + - name: Build the dockerized server example + working-directory: . + run: docker build -f e2e/java-plugin/Dockerfile -t e2e-java-plugin . diff --git a/e2e/compose.yml b/e2e/compose.yml index 79b78c8e58..28479057fd 100644 --- a/e2e/compose.yml +++ b/e2e/compose.yml @@ -48,3 +48,10 @@ services: args: IMAGE_NAME_BASE: ${IMAGE_NAME_BASE-ghcr.io/highlight/e2e:latest} container_name: ruby + java: + image: ${IMAGE_NAME-ghcr.io/highlight/e2e-java:latest} + build: + # The plugin is compiled from source, so the context is the repo root. + context: .. + dockerfile: e2e/java-plugin/Dockerfile + container_name: java diff --git a/e2e/java-plugin/Dockerfile b/e2e/java-plugin/Dockerfile new file mode 100644 index 0000000000..b71bd038a2 --- /dev/null +++ b/e2e/java-plugin/Dockerfile @@ -0,0 +1,18 @@ +# Build context is the repository root: +# docker build -f e2e/java-plugin/Dockerfile -t e2e-java-plugin . +# The plugin is built from source, so the image never needs a published artifact. +FROM eclipse-temurin:21-jdk AS build +WORKDIR /src +COPY sdk/@launchdarkly/observability-java sdk/@launchdarkly/observability-java +COPY e2e/java-plugin e2e/java-plugin +WORKDIR /src/e2e/java-plugin +RUN ../../sdk/@launchdarkly/observability-java/gradlew -p . installDist --no-daemon --console=plain \ + -PmainClass=com.launchdarkly.observability.example.ServerExample + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /src/e2e/java-plugin/build/install/java-plugin-example /app +ENV PORT=8080 +ENV BACKEND_URI="http://localhost:8080/" +EXPOSE 8080 +CMD ["/app/bin/java-plugin-example"] diff --git a/e2e/java-plugin/Makefile b/e2e/java-plugin/Makefile index 4ae158aa76..20245a31be 100644 --- a/e2e/java-plugin/Makefile +++ b/e2e/java-plugin/Makefile @@ -1,6 +1,21 @@ -.PHONY: run +.PHONY: run check serve consumer-check -SDK_GRADLE := ../../sdk/@launchdarkly/observability-java/gradlew +SDK_DIR := ../../sdk/@launchdarkly/observability-java +SDK_GRADLE := $(SDK_DIR)/gradlew +GRADLE_ARGS := --no-daemon --console=plain -run: - $(SDK_GRADLE) -p $(CURDIR) run --no-daemon --console=plain +# Self-checking example against the plugin sources in this repository. +run check: + $(SDK_GRADLE) -p $(CURDIR) run $(GRADLE_ARGS) + +# HTTP server example. Honours LAUNCHDARKLY_SDK_KEY, PORT, +# OTEL_EXPORTER_OTLP_ENDPOINT and LD_OBSERVABILITY_BACKEND_URL. +serve: + $(SDK_GRADLE) -p $(CURDIR) run $(GRADLE_ARGS) -PmainClass=com.launchdarkly.observability.example.ServerExample + +# Publishes the plugin to ~/.m2 and builds this example against that +# artifact, the way an application would. Catches POM scope mistakes and +# transitive dependency leaks that the composite build hides. +consumer-check: + cd $(SDK_DIR) && ./gradlew :lib:publishToMavenLocal $(GRADLE_ARGS) + $(SDK_GRADLE) -p $(CURDIR) check run $(GRADLE_ARGS) -PuseMavenLocal diff --git a/e2e/java-plugin/README.md b/e2e/java-plugin/README.md index 3d1cf08be5..97f62a336c 100644 --- a/e2e/java-plugin/README.md +++ b/e2e/java-plugin/README.md @@ -1,15 +1,64 @@ -# Java observability plugin example +# Java observability plugin examples -Runnable check for the LaunchDarkly Java server observability plugin. It starts a local OTLP and sampling endpoint, registers `ObservabilityPlugin` on an offline Java server SDK client, and exits non-zero unless the collector saw the expected spans. +Two runnable programs for the LaunchDarkly Java server observability plugin. +Both build against the plugin sources in this repository by default, so they +never need a published artifact. -## Run +## Self-check (`make run`) -From this directory: +`PluginExample` starts a local OTLP and sampling endpoint, registers +`ObservabilityPlugin` on an offline Java server SDK client, and exits non-zero +unless the collector saw the expected signals. A passing run prints one +`RESULT` line. `checkout-enabled` is the flag-evaluation span from +`TracingHook`. `kept-checkout` is kept by the sampling rule and carries +`launchdarkly.sampling.ratio`. `health-check` is dropped. A call to +`LDObserve` before the client starts must not install the OpenTelemetry no-op +global; the flag span only arrives when the hook is on the plugin's exporters. ```bash make run ``` -That uses the Java SDK's Gradle wrapper and the local `observability-java` project, so the example does not need a published artifact. +## HTTP server (`make serve`) -A passing run prints one `RESULT` line. `checkout-enabled` is the flag-evaluation span from `TracingHook`. `kept-checkout` is kept by the sampling rule and carries `launchdarkly.sampling.ratio`. `health-check` is dropped. A call to `LDObserve` before the client starts must not install the OpenTelemetry no-op global; the flag span only arrives when the hook is on the plugin's exporters. +`ServerExample` is a plain `com.sun.net.httpserver` service on port 8080 with +`/`, `/rolldice` and `/error`. Each request is a span, evaluates a flag (which +the plugin records on the span), records a histogram, a counter and a log; the +`/error` route records an exception. + +```bash +export LAUNCHDARKLY_SDK_KEY="key-from-your-project" # unset = offline client +make serve & +until curl -s -o /dev/null localhost:8080/; do sleep 1; done +curl -s localhost:8080/rolldice +curl -s localhost:8080/error +``` + +Environment the server honours: + +| Variable | Purpose | +|----------|---------| +| `LAUNCHDARKLY_SDK_KEY` | SDK key. Unset runs the client offline. | +| `PORT` | Listen port, default `8080`. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP base URL, e.g. `http://localhost:4318` for a local stack. | +| `LD_OBSERVABILITY_BACKEND_URL` | Sampling-config URL for a local stack. | +| `HTTPS_PROXY` | `host:port` of an egress proxy; applied to exports and the sampling request. | + +### Docker + +The image is built from the repository root so the plugin can be compiled from +source. It is also wired into `e2e/compose.yml` as the `java` service, which +the dockerized e2e workflow starts and health-checks. + +```bash +docker build -f e2e/java-plugin/Dockerfile -t e2e-java-plugin . +docker run --rm -p 8080:8080 -e LAUNCHDARKLY_SDK_KEY e2e-java-plugin +``` + +## Consumer check (`make consumer-check`) + +Publishes the plugin to `~/.m2` and builds the example against that artifact +with `-PuseMavenLocal`, the way an application would. This verifies the +published POM: the example compiles against `Attributes` and `Severity` without +declaring `opentelemetry-api` itself, and no OkHttp, Okio or Kotlin arrives +transitively. CI runs this on every change to the plugin. diff --git a/e2e/java-plugin/build.gradle b/e2e/java-plugin/build.gradle index cdd914db07..34a4de7910 100644 --- a/e2e/java-plugin/build.gradle +++ b/e2e/java-plugin/build.gradle @@ -2,7 +2,12 @@ plugins { id 'application' } +def useMavenLocal = project.hasProperty('useMavenLocal') + repositories { + if (useMavenLocal) { + mavenLocal() + } mavenCentral() } @@ -11,12 +16,48 @@ java { targetCompatibility = JavaVersion.VERSION_11 } +// The plugin version comes from the SDK's gradle.properties, which +// release-please bumps, so this example never pins a stale version. +def sdkProperties = new Properties() +file('../../sdk/@launchdarkly/observability-java/gradle.properties').withInputStream { sdkProperties.load(it) } +def pluginVersion = sdkProperties.getProperty('version') + dependencies { - implementation 'com.launchdarkly:launchdarkly-observability-java:0.1.0' + implementation "com.launchdarkly:launchdarkly-java-server-sdk-observability:${pluginVersion}" implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' - implementation 'io.opentelemetry:opentelemetry-api:1.51.0' + // No explicit opentelemetry-api: the example uses Attributes and Severity, + // which must arrive through the plugin's compile-scoped dependency. } application { - mainClass = 'com.launchdarkly.observability.example.PluginExample' + mainClass = project.findProperty('mainClass') ?: 'com.launchdarkly.observability.example.PluginExample' +} + +// Groups that must never reach a consumer's runtime classpath through the plugin. +def forbiddenRuntimeGroups = ['com.squareup.okhttp3', 'com.squareup.okio', 'org.jetbrains.kotlin', 'org.jetbrains.kotlinx'] + +tasks.register('checkConsumerClasspath') { + group = 'verification' + description = 'Fails when the plugin leaks OkHttp, Okio or Kotlin into this consumer, or fails to provide opentelemetry-api.' + def runtime = configurations.runtimeClasspath + def compile = configurations.compileClasspath + doLast { + def leaked = runtime.resolvedConfiguration.resolvedArtifacts + .collect { it.moduleVersion.id } + .findAll { forbiddenRuntimeGroups.contains(it.group) } + .collect { "${it.group}:${it.name}:${it.version}" } + .unique() + if (!leaked.isEmpty()) { + throw new GradleException("Plugin leaked forbidden artifacts into the consumer runtime classpath: ${leaked.join(', ')}") + } + def otelApi = compile.resolvedConfiguration.resolvedArtifacts + .find { it.moduleVersion.id.group == 'io.opentelemetry' && it.moduleVersion.id.name == 'opentelemetry-api' } + if (otelApi == null) { + throw new GradleException('opentelemetry-api is not on the consumer compile classpath; the plugin must publish it with compile scope') + } + } +} + +tasks.named('check') { + dependsOn 'checkConsumerClasspath' } diff --git a/e2e/java-plugin/settings.gradle b/e2e/java-plugin/settings.gradle index 4c36721fb1..75ca90cbf7 100644 --- a/e2e/java-plugin/settings.gradle +++ b/e2e/java-plugin/settings.gradle @@ -1,7 +1,15 @@ rootProject.name = 'java-plugin-example' -includeBuild('../../sdk/@launchdarkly/observability-java') { - dependencySubstitution { - substitute module('com.launchdarkly:launchdarkly-observability-java') using project(':lib') +// By default the example builds against the plugin sources in this repository, +// so it needs no published artifact. Pass -PuseMavenLocal to resolve the +// plugin from ~/.m2 instead (after `./gradlew :lib:publishToMavenLocal` in the +// SDK directory). CI uses that mode to check the published POM: the example +// must compile against OpenTelemetry API types it never declares itself, and +// no OkHttp, Okio or Kotlin may arrive transitively. +if (!gradle.startParameter.projectProperties.containsKey('useMavenLocal')) { + includeBuild('../../sdk/@launchdarkly/observability-java') { + dependencySubstitution { + substitute module('com.launchdarkly:launchdarkly-java-server-sdk-observability') using project(':lib') + } } } diff --git a/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/ServerExample.java b/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/ServerExample.java new file mode 100644 index 0000000000..d7035b9db4 --- /dev/null +++ b/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/ServerExample.java @@ -0,0 +1,168 @@ +package com.launchdarkly.observability.example; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +/** + * A long-running HTTP server that uses the LaunchDarkly Java Server SDK with + * the observability plugin, the way a service would. Every request is traced, + * evaluates a flag (which the plugin records as a span event), records a + * metric and a log, and some routes record an error. + * + *

Environment:

+ *
    + *
  • {@code LAUNCHDARKLY_SDK_KEY}: the SDK key. When unset the client runs + * offline, so the server still works without LaunchDarkly access.
  • + *
  • {@code PORT}: listen port, default 8080.
  • + *
  • {@code OTEL_EXPORTER_OTLP_ENDPOINT}: OTLP/HTTP base URL. Defaults to + * the LaunchDarkly endpoint.
  • + *
  • {@code LD_OBSERVABILITY_BACKEND_URL}: sampling-config URL. Defaults to + * the LaunchDarkly backend.
  • + *
  • {@code HTTPS_PROXY} / {@code https_proxy}: {@code host:port} of an + * egress proxy for exports and the sampling request.
  • + *
+ */ +public final class ServerExample { + private static final AttributeKey ROUTE = AttributeKey.stringKey("http.route"); + private static final AttributeKey STATUS = AttributeKey.longKey("http.response.status_code"); + + public static void main(String[] args) throws Exception { + String sdkKey = env("LAUNCHDARKLY_SDK_KEY", ""); + int port = Integer.parseInt(env("PORT", "8080")); + + ObservabilityOptions.Builder options = new ObservabilityOptions.Builder() + .serviceName("java-plugin-example") + .serviceVersion(env("SERVICE_VERSION", "dev")) + .environment("test") + .resourceAttributes(Attributes.of(AttributeKey.stringKey("example.runtime"), System.getProperty("java.version"))) + .logsApiLevel(ObservabilityOptions.LogLevel.INFO); + String otlp = env("OTEL_EXPORTER_OTLP_ENDPOINT", ""); + if (!otlp.isEmpty()) { + options.otlpEndpoint(otlp); + } + String backend = env("LD_OBSERVABILITY_BACKEND_URL", ""); + if (!backend.isEmpty()) { + options.backendUrl(backend); + } + String proxy = env("HTTPS_PROXY", env("https_proxy", "")); + if (!proxy.isEmpty()) { + String hostPort = proxy.replaceFirst("^[a-z]+://", "").replaceAll("/.*$", ""); + int colon = hostPort.lastIndexOf(':'); + options.proxyHostAndPort(hostPort.substring(0, colon), Integer.parseInt(hostPort.substring(colon + 1))); + } + + LDConfig config = new LDConfig.Builder() + .offline(sdkKey.isEmpty()) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options.build())))) + .build(); + LDClient client = new LDClient(sdkKey.isEmpty() ? "offline" : sdkKey, config); + System.out.println("LaunchDarkly client initialized=" + client.isInitialized() + + (sdkKey.isEmpty() ? " (offline: LAUNCHDARKLY_SDK_KEY not set)" : "")); + + HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); + server.setExecutor(Executors.newFixedThreadPool(8)); + server.createContext("/", exchange -> handle(exchange, client, "/", ServerExample::index)); + server.createContext("/rolldice", exchange -> handle(exchange, client, "/rolldice", ServerExample::rollDice)); + server.createContext("/error", exchange -> handle(exchange, client, "/error", ServerExample::error)); + server.start(); + System.out.println("Listening on http://0.0.0.0:" + port + " (routes: /, /rolldice, /error)"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + server.stop(1); + try { + client.close(); + } catch (IOException ignored) { + // Nothing left to do on shutdown. + } + LDObserve.shutdown(); + }, "example-shutdown")); + + Thread.currentThread().join(); + } + + @FunctionalInterface + private interface Route { + String apply(LDClient client, LDContext context) throws Exception; + } + + private static void handle(HttpExchange exchange, LDClient client, String route, Route body) throws IOException { + long started = System.nanoTime(); + Span span = LDObserve.startSpan("HTTP " + exchange.getRequestMethod() + " " + route, + Attributes.of(ROUTE, route)); + int status = 200; + String response; + try (Scope ignored = span.makeCurrent()) { + LDContext context = LDContext.builder("user-" + ThreadLocalRandom.current().nextInt(1000)).build(); + response = body.apply(client, context); + } catch (Exception e) { + status = 500; + response = "error: " + e.getMessage() + "\n"; + span.setStatus(StatusCode.ERROR, e.getMessage()); + LDObserve.recordError(e, Attributes.of(ROUTE, route)); + } finally { + span.setAttribute(STATUS, (long) status); + span.end(); + } + double millis = TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - started) / 1000.0; + LDObserve.recordHistogram("http.server.duration_ms", millis, Attributes.of(ROUTE, route)); + LDObserve.recordIncr("http.server.requests", Attributes.of(ROUTE, route, STATUS, (long) status)); + LDObserve.recordLog(exchange.getRequestMethod() + " " + route + " -> " + status, + status >= 500 ? Severity.ERROR : Severity.INFO, Attributes.of(ROUTE, route)); + + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + private static String index(LDClient client, LDContext context) { + boolean enabled = client.boolVariation("example-greeting", context, false); + return (enabled ? "Hello from the flagged path" : "Hello, World!") + "\n"; + } + + private static String rollDice(LDClient client, LDContext context) { + int sides = (int) client.intVariation("dice-sides", context, 6); + int roll = ThreadLocalRandom.current().nextInt(1, sides + 1); + Span.current().setAttribute("dice.roll", roll); + return roll + "\n"; + } + + private static String error(LDClient client, LDContext context) { + boolean soft = client.boolVariation("soft-errors", context, false); + if (soft) { + return "soft error recorded\n"; + } + throw new IllegalStateException("random error from the Java plugin example"); + } + + private static String env(String name, String fallback) { + String value = System.getenv(name); + return value == null || value.isEmpty() ? fallback : value; + } +} diff --git a/e2e/tests/src/app_runner.py b/e2e/tests/src/app_runner.py index c68b076eaa..a9dd6b472d 100644 --- a/e2e/tests/src/app_runner.py +++ b/e2e/tests/src/app_runner.py @@ -179,6 +179,7 @@ def main(): "dotnet", "dotnet4", "go", + "java", "python", "ruby", ], diff --git a/sdk/@launchdarkly/observability-java/README.md b/sdk/@launchdarkly/observability-java/README.md index 5cc49dd926..675510e0f8 100644 --- a/sdk/@launchdarkly/observability-java/README.md +++ b/sdk/@launchdarkly/observability-java/README.md @@ -1,4 +1,4 @@ -# LaunchDarkly Observability Java SDK +# LaunchDarkly Observability plugin for the Java Server SDK Official LaunchDarkly Observability plugin for the [Java Server SDK](https://github.com/launchdarkly/java-core). This plugin integrates OpenTelemetry tracing, logging, and metrics with LaunchDarkly feature flag evaluations. @@ -11,11 +11,13 @@ Official LaunchDarkly Observability plugin for the [Java Server SDK](https://git ## Installation +The artifact follows the Java server SDK add-on family (`launchdarkly-java-server-sdk-otel`, `-redis-store`, `-ai`). The Android observability plugin is a separate artifact, `launchdarkly-observability-android`. + ### Gradle ```groovy dependencies { - implementation 'com.launchdarkly:launchdarkly-observability-java:0.1.0' + implementation 'com.launchdarkly:launchdarkly-java-server-sdk-observability:0.1.0' implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' } ``` @@ -25,11 +27,17 @@ dependencies { ```xml com.launchdarkly - launchdarkly-observability-java + launchdarkly-java-server-sdk-observability 0.1.0 ``` +### Dependencies you get + +- `io.opentelemetry:opentelemetry-api` is a compile-scoped (`api`) dependency, because `LDObserve` and `ObservabilityOptions` take OpenTelemetry API types such as `Attributes` and `Severity`. You do not need to declare it yourself. +- OTLP is sent over HTTP with OpenTelemetry's JDK sender, which uses `java.net.http.HttpClient`. The plugin does **not** pull OkHttp, Okio or the Kotlin standard library onto your classpath. The build fails if any of those leak in. +- The OpenTelemetry SDK, the LaunchDarkly OTel tracing hook and Gson are runtime dependencies. + ## Usage ```java @@ -96,17 +104,56 @@ LDObserve.flush(); LDObserve.shutdown(); ``` +Every `LDObserve` method is safe to call before the plugin is initialized; such calls are local no-ops and do not install the OpenTelemetry no-op global. + ## Configuration Options | Option | Default | Description | |--------|---------|-------------| +| `enabled` | `true` | When `false`, no providers start, no flag-evaluation hook is registered, no sampling config is fetched, and `LDObserve` is a no-op. | | `serviceName` | `""` | Service name for resource attributes | | `serviceVersion` | `""` | Service version for resource attributes | | `environment` | `""` | Deployment environment name | -| `otlpEndpoint` | LaunchDarkly endpoint | Custom OTLP endpoint | +| `otlpEndpoint` | LaunchDarkly endpoint | Custom OTLP/HTTP endpoint | | `backendUrl` | LaunchDarkly backend | Custom backend URL for sampling config | +| `resourceAttributes` | empty | Extra resource attributes merged over the plugin's defaults, so they can override `service.name` and friends | +| `customHeaders` / `customHeader` | none | Headers sent with every OTLP export and the sampling-config request | +| `proxySelector` / `proxyHostAndPort` | JVM default | Egress proxy for exports and the sampling-config request | +| `logsApiLevel` | `INFO` | Minimum severity for exported logs. `NONE` disables log export. | | `debug` | `false` | Enable debug logging | -| `manualStart` | `false` | Delay OTLP provider startup | +| `manualStart` | `false` | Delay OTLP provider startup until `LDObserve.start(sdkKey, options)` | + +### Proxies and custom headers + +Both the OTLP exporters and the sampling-config request use `java.net.http.HttpClient`, so they behave the same way behind an egress proxy. With nothing configured, the JVM default proxy selector applies, which honours `-Dhttps.proxyHost`, `-Dhttps.proxyPort` and `-Dhttp.nonProxyHosts`. To set a proxy or headers explicitly: + +```java +new ObservabilityOptions.Builder() + .proxyHostAndPort("egress.internal", 3128) + .customHeader("X-Tenant", "acme") + .build(); +``` + +`proxySelector(java.net.ProxySelector)` accepts any selector when per-host rules are needed. + +### Log level + +```java +new ObservabilityOptions.Builder() + .logsApiLevel(ObservabilityOptions.LogLevel.WARN) // export WARN, ERROR and FATAL + .build(); +``` + +Levels are `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL` and `NONE`, matching the OpenTelemetry severity numbers. A record whose severity is unset is treated as `TRACE`. + +## Development + +```bash +./gradlew :lib:build # compile, unit tests, classpath and POM guards +./gradlew :lib:publishToMavenLocal # then see ../../e2e/java-plugin for consumer and server examples +``` + +`:lib:build` fails if OkHttp, Okio or Kotlin appear on the runtime classpath, or if the generated POM does not expose `opentelemetry-api` at compile scope. ## License From 1bb34c376325319dd3c3a2f301c55b7a7d2143a3 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 20:27:34 +0000 Subject: [PATCH 11/17] test: add a Spring Boot web backend as the Java plugin e2e app Replaces the plain com.sun.net.httpserver example with a Spring Boot 3 service, which is what the plugin will be tested against and what new server-side features will be driven from. A servlet filter opens a span per request and records a duration histogram, a request counter and a log line; every handler evaluates a flag inside that span so the hook's span events have a parent; /error goes through a controller advice that records the exception with LDObserve before answering 500; /flags/{key} returns any flag's JSON value; actuator health is exposed. The client runs offline when LAUNCHDARKLY_SDK_KEY is unset, so CI needs no secrets. The app builds against the plugin sources by default and against the Maven local artifact with -PuseMavenLocal; a classpath guard fails if the plugin leaks OkHttp, Okio or Kotlin into it. CI builds and tests it, drives every route over curl, checks the client closes on SIGTERM, and builds its Dockerfile; the dockerized e2e java service now points at it. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- .github/workflows/java-observability.yml | 9 + .github/workflows/java-plugin-e2e.yml | 41 +++-- .github/workflows/turbo.yml | 1 + e2e/compose.yml | 2 +- e2e/java-plugin-spring-boot/.gitignore | 2 + e2e/java-plugin-spring-boot/Dockerfile | 18 ++ e2e/java-plugin-spring-boot/Makefile | 23 +++ e2e/java-plugin-spring-boot/README.md | 71 ++++++++ e2e/java-plugin-spring-boot/build.gradle | 64 +++++++ e2e/java-plugin-spring-boot/settings.gradle | 20 +++ .../example/springboot/ApiController.java | 62 +++++++ .../example/springboot/Application.java | 29 +++ .../example/springboot/ErrorHandling.java | 28 +++ .../springboot/LaunchDarklyConfiguration.java | 79 ++++++++ .../springboot/RequestTelemetryFilter.java | 59 ++++++ .../src/main/resources/application.properties | 8 + e2e/java-plugin/Dockerfile | 18 -- e2e/java-plugin/Makefile | 7 +- e2e/java-plugin/README.md | 65 ++----- e2e/java-plugin/build.gradle | 2 +- .../observability/example/ServerExample.java | 168 ------------------ 21 files changed, 520 insertions(+), 256 deletions(-) create mode 100644 e2e/java-plugin-spring-boot/.gitignore create mode 100644 e2e/java-plugin-spring-boot/Dockerfile create mode 100644 e2e/java-plugin-spring-boot/Makefile create mode 100644 e2e/java-plugin-spring-boot/README.md create mode 100644 e2e/java-plugin-spring-boot/build.gradle create mode 100644 e2e/java-plugin-spring-boot/settings.gradle create mode 100644 e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ApiController.java create mode 100644 e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/Application.java create mode 100644 e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ErrorHandling.java create mode 100644 e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/LaunchDarklyConfiguration.java create mode 100644 e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/RequestTelemetryFilter.java create mode 100644 e2e/java-plugin-spring-boot/src/main/resources/application.properties delete mode 100644 e2e/java-plugin/Dockerfile delete mode 100644 e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/ServerExample.java diff --git a/.github/workflows/java-observability.yml b/.github/workflows/java-observability.yml index 3300def213..37bbc35b7b 100644 --- a/.github/workflows/java-observability.yml +++ b/.github/workflows/java-observability.yml @@ -6,12 +6,14 @@ on: paths: - 'sdk/@launchdarkly/observability-java/**' - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' - '.github/workflows/java-observability.yml' pull_request: types: [opened, synchronize] paths: - 'sdk/@launchdarkly/observability-java/**' - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' - '.github/workflows/java-observability.yml' permissions: @@ -84,3 +86,10 @@ jobs: - name: Publish the plugin to Maven local and build the example against it run: make consumer-check + + # Spring Boot 3 needs Java 17, so the web backend is checked as a + # consumer on the newer JDK only. + - name: Build the Spring Boot backend against the Maven local artifact + if: matrix.java != 11 + working-directory: ./e2e/java-plugin-spring-boot + run: make consumer-check diff --git a/.github/workflows/java-plugin-e2e.yml b/.github/workflows/java-plugin-e2e.yml index 8337b49e9e..7a30fadd48 100644 --- a/.github/workflows/java-plugin-e2e.yml +++ b/.github/workflows/java-plugin-e2e.yml @@ -5,12 +5,14 @@ on: branches: ['main'] paths: - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' - 'sdk/@launchdarkly/observability-java/**' - '.github/workflows/java-plugin-e2e.yml' pull_request: types: [opened, synchronize] paths: - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' - 'sdk/@launchdarkly/observability-java/**' - '.github/workflows/java-plugin-e2e.yml' @@ -40,26 +42,35 @@ jobs: - name: Run the self-checking example run: make run - # Starts the HTTP server example offline (no SDK key) and drives - # every route. The server must come up, answer, and shut down - # cleanly on SIGTERM with the plugin flushing on the way out. - - name: Smoke-test the HTTP server example + # Builds the Spring Boot web backend, starts it offline (no SDK + # key) and drives every route. It must come up, answer, record the + # /error exception as a 500, and stop cleanly on SIGTERM with the + # plugin flushing on the way out. + - name: Build and test the Spring Boot backend + working-directory: ./e2e/java-plugin-spring-boot + run: make test build + + - name: Smoke-test the Spring Boot backend + working-directory: ./e2e/java-plugin-spring-boot run: | - make serve > server.log 2>&1 & - SERVER_PID=$! + java -jar build/libs/*.jar > app.log 2>&1 & + APP_PID=$! for i in $(seq 1 120); do if curl -sf -o /dev/null localhost:8080/; then break; fi - if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "server exited early"; cat server.log; exit 1; fi + if ! kill -0 "$APP_PID" 2>/dev/null; then echo "app exited early"; cat app.log; exit 1; fi sleep 1 done - curl -sf localhost:8080/ || { cat server.log; exit 1; } - curl -sf localhost:8080/rolldice || { cat server.log; exit 1; } + curl -sf localhost:8080/ || { cat app.log; exit 1; } + curl -sf -H 'X-User-Key: ci' localhost:8080/rolldice || { cat app.log; exit 1; } + curl -sf localhost:8080/flags/example-greeting || { cat app.log; exit 1; } + curl -sf localhost:8080/actuator/health || { cat app.log; exit 1; } code=$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/error) - [ "$code" = "500" ] || { echo "expected 500 from /error, got $code"; cat server.log; exit 1; } - kill -TERM "$SERVER_PID" - wait "$SERVER_PID" || true - echo "server example OK" + [ "$code" = "500" ] || { echo "expected 500 from /error, got $code"; cat app.log; exit 1; } + kill -TERM "$APP_PID" + wait "$APP_PID" || true + grep -q "Closing LaunchDarkly Client" app.log || { echo "client did not close on shutdown"; cat app.log; exit 1; } + echo "Spring Boot backend OK" - - name: Build the dockerized server example + - name: Build the dockerized Spring Boot backend working-directory: . - run: docker build -f e2e/java-plugin/Dockerfile -t e2e-java-plugin . + run: docker build -f e2e/java-plugin-spring-boot/Dockerfile -t e2e-java . diff --git a/.github/workflows/turbo.yml b/.github/workflows/turbo.yml index aa3725b490..93b4083f47 100644 --- a/.github/workflows/turbo.yml +++ b/.github/workflows/turbo.yml @@ -70,6 +70,7 @@ jobs: - '!sdk/@launchdarkly/observability-ruby/**' - '!e2e/android/**' - '!e2e/java-plugin/**' + - '!e2e/java-plugin-spring-boot/**' - '!.github/workflows/android-observability.yml' - '!.github/workflows/android-e2e.yml' - '!.github/workflows/flutter-observability.yml' diff --git a/e2e/compose.yml b/e2e/compose.yml index 28479057fd..486e4b8060 100644 --- a/e2e/compose.yml +++ b/e2e/compose.yml @@ -53,5 +53,5 @@ services: build: # The plugin is compiled from source, so the context is the repo root. context: .. - dockerfile: e2e/java-plugin/Dockerfile + dockerfile: e2e/java-plugin-spring-boot/Dockerfile container_name: java diff --git a/e2e/java-plugin-spring-boot/.gitignore b/e2e/java-plugin-spring-boot/.gitignore new file mode 100644 index 0000000000..f8b92c3aa0 --- /dev/null +++ b/e2e/java-plugin-spring-boot/.gitignore @@ -0,0 +1,2 @@ +.gradle +build diff --git a/e2e/java-plugin-spring-boot/Dockerfile b/e2e/java-plugin-spring-boot/Dockerfile new file mode 100644 index 0000000000..22328acc66 --- /dev/null +++ b/e2e/java-plugin-spring-boot/Dockerfile @@ -0,0 +1,18 @@ +# Build context is the repository root: +# docker build -f e2e/java-plugin-spring-boot/Dockerfile -t e2e-java . +# The plugin is built from source, so the image never needs a published artifact. +FROM eclipse-temurin:21-jdk AS build +WORKDIR /src +COPY sdk/@launchdarkly/observability-java sdk/@launchdarkly/observability-java +COPY e2e/java-plugin-spring-boot e2e/java-plugin-spring-boot +WORKDIR /src/e2e/java-plugin-spring-boot +RUN ../../sdk/@launchdarkly/observability-java/gradlew -p . bootJar --no-daemon --console=plain \ + && cp build/libs/*.jar /src/app.jar + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /src/app.jar /app/app.jar +ENV PORT=8080 +ENV BACKEND_URI="http://localhost:8080/" +EXPOSE 8080 +CMD ["java", "-jar", "/app/app.jar"] diff --git a/e2e/java-plugin-spring-boot/Makefile b/e2e/java-plugin-spring-boot/Makefile new file mode 100644 index 0000000000..c0373906d4 --- /dev/null +++ b/e2e/java-plugin-spring-boot/Makefile @@ -0,0 +1,23 @@ +.PHONY: build run test consumer-check + +SDK_DIR := ../../sdk/@launchdarkly/observability-java +SDK_GRADLE := $(SDK_DIR)/gradlew +GRADLE_ARGS := --no-daemon --console=plain + +# Builds the executable Spring Boot jar at build/libs/*.jar. +build: + $(SDK_GRADLE) -p $(CURDIR) bootJar $(GRADLE_ARGS) + +# Runs the web backend on PORT (default 8080). Honours LAUNCHDARKLY_SDK_KEY, +# OTEL_EXPORTER_OTLP_ENDPOINT, LD_OBSERVABILITY_BACKEND_URL and HTTPS_PROXY. +run: + $(SDK_GRADLE) -p $(CURDIR) bootRun $(GRADLE_ARGS) + +# Unit tests plus the runtime-classpath guard. +test: + $(SDK_GRADLE) -p $(CURDIR) check $(GRADLE_ARGS) + +# Publishes the plugin to ~/.m2 and builds the app against that artifact. +consumer-check: + cd $(SDK_DIR) && ./gradlew :lib:publishToMavenLocal $(GRADLE_ARGS) + $(SDK_GRADLE) -p $(CURDIR) check bootJar $(GRADLE_ARGS) -PuseMavenLocal diff --git a/e2e/java-plugin-spring-boot/README.md b/e2e/java-plugin-spring-boot/README.md new file mode 100644 index 0000000000..4bbd67275f --- /dev/null +++ b/e2e/java-plugin-spring-boot/README.md @@ -0,0 +1,71 @@ +# Spring Boot web backend with the Java observability plugin + +A Spring Boot 3 service that uses the LaunchDarkly Java Server SDK with the +observability plugin the way a production backend does. It is the e2e app for +the plugin: CI builds it, runs it, and drives its routes, and it is the place +to exercise features the plugin needs on a server (request spans, flag +evaluations under load, errors, proxies, custom headers). + +It builds against the plugin sources in this repository by default, so it +never needs a published artifact. + +## Routes + +| Route | What it exercises | +|-------|-------------------| +| `GET /` | Flag evaluation (`example-greeting`) inside the request span | +| `GET /rolldice` | Flag evaluation (`dice-sides`), a span attribute, JSON response | +| `GET /flags/{key}` | Any flag's JSON value for the request context | +| `GET /error` | An unhandled exception recorded with `LDObserve.recordError`, answered 500 | +| `GET /actuator/health` | Spring Boot health | + +Send `X-User-Key: ` to pin the LaunchDarkly context; otherwise a random +user key is used per request. Every request gets a span, a duration histogram, +a request counter and a log line from `RequestTelemetryFilter`. + +## Run + +```bash +export LAUNCHDARKLY_SDK_KEY="key-from-your-project" # unset = offline client +make run & +until curl -s -o /dev/null localhost:8080/; do sleep 1; done +curl -s localhost:8080/rolldice +curl -s localhost:8080/error +``` + +Environment the app honours: + +| Variable | Purpose | +|----------|---------| +| `LAUNCHDARKLY_SDK_KEY` | SDK key. Unset runs the client offline. | +| `PORT` | Listen port, default `8080`. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP base URL, e.g. `http://localhost:4318` for a local stack. | +| `LD_OBSERVABILITY_BACKEND_URL` | Sampling-config URL for a local stack. | +| `HTTPS_PROXY` | `host:port` of an egress proxy; applied to exports and the sampling request. | +| `SERVICE_VERSION` | Reported as `service.version`, default `dev`. | + +`make build` produces the executable jar in `build/libs/`, and `make test` +runs the tests plus a guard that fails if the plugin leaks OkHttp, Okio or +Kotlin onto the application's classpath. + +## Docker + +The image is built from the repository root so the plugin can be compiled +from source. It is the `java` service in `e2e/compose.yml`, which the +dockerized e2e workflow builds, starts and health-checks. + +```bash +docker build -f e2e/java-plugin-spring-boot/Dockerfile -t e2e-java . +docker run --rm -p 8080:8080 -e LAUNCHDARKLY_SDK_KEY e2e-java +``` + +## Consumer check + +```bash +make consumer-check +``` + +Publishes the plugin to `~/.m2` and builds the app against that artifact with +`-PuseMavenLocal`, the way an application would. The app compiles against +`Attributes`, `Severity` and `Span` without declaring `opentelemetry-api` +itself, which checks the published POM's scopes. diff --git a/e2e/java-plugin-spring-boot/build.gradle b/e2e/java-plugin-spring-boot/build.gradle new file mode 100644 index 0000000000..f0f145cc0b --- /dev/null +++ b/e2e/java-plugin-spring-boot/build.gradle @@ -0,0 +1,64 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.16' + id 'io.spring.dependency-management' version '1.1.7' +} + +def useMavenLocal = project.hasProperty('useMavenLocal') + +repositories { + if (useMavenLocal) { + mavenLocal() + } + mavenCentral() +} + +java { + // Spring Boot 3 needs Java 17. The plugin itself targets Java 11. + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +// The plugin version comes from the SDK's gradle.properties, which +// release-please bumps, so this app never pins a stale version. +def sdkProperties = new Properties() +file('../../sdk/@launchdarkly/observability-java/gradle.properties').withInputStream { sdkProperties.load(it) } +def pluginVersion = sdkProperties.getProperty('version') + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation "com.launchdarkly:launchdarkly-java-server-sdk-observability:${pluginVersion}" + implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' + // No explicit opentelemetry-api: Attributes, Severity and Span arrive + // through the plugin's compile-scoped dependency. + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +tasks.named('test') { + useJUnitPlatform() +} + +// Groups that must never reach an application's runtime classpath through the plugin. +def forbiddenRuntimeGroups = ['com.squareup.okhttp3', 'com.squareup.okio', 'org.jetbrains.kotlin', 'org.jetbrains.kotlinx'] + +tasks.register('checkConsumerClasspath') { + group = 'verification' + description = 'Fails when the plugin leaks OkHttp, Okio or Kotlin into this application.' + def runtime = configurations.runtimeClasspath + doLast { + def leaked = runtime.resolvedConfiguration.resolvedArtifacts + .collect { it.moduleVersion.id } + .findAll { forbiddenRuntimeGroups.contains(it.group) } + .collect { "${it.group}:${it.name}:${it.version}" } + .unique() + if (!leaked.isEmpty()) { + throw new GradleException("Forbidden artifacts on the application runtime classpath: ${leaked.join(', ')}") + } + } +} + +tasks.named('check') { + dependsOn 'checkConsumerClasspath' +} diff --git a/e2e/java-plugin-spring-boot/settings.gradle b/e2e/java-plugin-spring-boot/settings.gradle new file mode 100644 index 0000000000..957c2ef056 --- /dev/null +++ b/e2e/java-plugin-spring-boot/settings.gradle @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = 'java-plugin-spring-boot-example' + +// By default the app builds against the plugin sources in this repository, so +// it needs no published artifact. Pass -PuseMavenLocal to resolve the plugin +// from ~/.m2 instead (after `./gradlew :lib:publishToMavenLocal` in the SDK +// directory), which is how an application would consume it. +if (!gradle.startParameter.projectProperties.containsKey('useMavenLocal')) { + includeBuild('../../sdk/@launchdarkly/observability-java') { + dependencySubstitution { + substitute module('com.launchdarkly:launchdarkly-java-server-sdk-observability') using project(':lib') + } + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ApiController.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ApiController.java new file mode 100644 index 0000000000..1881ca8979 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ApiController.java @@ -0,0 +1,62 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.LDClient; +import io.opentelemetry.api.trace.Span; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Routes that exercise the plugin: every handler evaluates a flag (recorded as + * a span event by the plugin's hook), and some record errors. + */ +@RestController +public class ApiController { + private final LDClient client; + + public ApiController(LDClient client) { + this.client = client; + } + + @GetMapping("/") + public String index(@RequestHeader(value = "X-User-Key", required = false) String userKey) { + boolean flagged = client.boolVariation("example-greeting", context(userKey), false); + return (flagged ? "Hello from the flagged path" : "Hello, World!") + "\n"; + } + + @GetMapping("/rolldice") + public Map rollDice(@RequestHeader(value = "X-User-Key", required = false) String userKey) { + LDContext context = context(userKey); + int sides = client.intVariation("dice-sides", context, 6); + int roll = ThreadLocalRandom.current().nextInt(1, sides + 1); + Span.current().setAttribute("dice.roll", roll); + return Map.of("roll", roll, "sides", sides, "user", context.getKey()); + } + + @GetMapping("/flags/{key}") + public Map flag(@PathVariable String key, + @RequestHeader(value = "X-User-Key", required = false) String userKey) { + LDContext context = context(userKey); + return Map.of("key", key, "value", client.jsonValueVariation(key, context, com.launchdarkly.sdk.LDValue.ofNull()).toJsonString()); + } + + @GetMapping("/error") + public String error(@RequestHeader(value = "X-User-Key", required = false) String userKey) { + if (client.boolVariation("soft-errors", context(userKey), false)) { + return "soft error recorded\n"; + } + throw new IllegalStateException("random error from the Spring Boot example"); + } + + private static LDContext context(String userKey) { + String key = userKey == null || userKey.isEmpty() + ? "user-" + ThreadLocalRandom.current().nextInt(1000) + : userKey; + return LDContext.builder(key).build(); + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/Application.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/Application.java new file mode 100644 index 0000000000..3a242a293b --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/Application.java @@ -0,0 +1,29 @@ +package com.launchdarkly.observability.example.springboot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * A Spring Boot web backend that uses the LaunchDarkly Java Server SDK with + * the observability plugin the way a service does: a flag-aware client bean, + * a span, metrics and a log per request, and recorded errors. + * + *

Environment:

+ *
    + *
  • {@code LAUNCHDARKLY_SDK_KEY}: the SDK key. When unset the client runs + * offline so the backend still works without LaunchDarkly access.
  • + *
  • {@code PORT}: listen port, default 8080.
  • + *
  • {@code OTEL_EXPORTER_OTLP_ENDPOINT}: OTLP/HTTP base URL. Defaults to + * the LaunchDarkly endpoint.
  • + *
  • {@code LD_OBSERVABILITY_BACKEND_URL}: sampling-config URL. Defaults to + * the LaunchDarkly backend.
  • + *
  • {@code HTTPS_PROXY}: {@code host:port} of an egress proxy for exports + * and the sampling request.
  • + *
+ */ +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ErrorHandling.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ErrorHandling.java new file mode 100644 index 0000000000..ec07fa5c94 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ErrorHandling.java @@ -0,0 +1,28 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.observability.LDObserve; +import io.opentelemetry.api.common.Attributes; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +/** + * Records every unhandled exception through the plugin before answering 500. + */ +@RestControllerAdvice +public class ErrorHandling { + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity notFound(NoHandlerFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("no route for " + e.getRequestURL() + "\n"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity unhandled(Exception e, HttpServletRequest request) { + LDObserve.recordError(e, Attributes.of(RequestTelemetryFilter.ROUTE, request.getRequestURI())); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("error: " + e.getMessage() + "\n"); + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/LaunchDarklyConfiguration.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/LaunchDarklyConfiguration.java new file mode 100644 index 0000000000..745eac33b2 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/LaunchDarklyConfiguration.java @@ -0,0 +1,79 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.List; + +/** + * Builds the one {@link LDClient} for the application with the observability + * plugin installed, and shuts both down with the context. + */ +@Configuration +public class LaunchDarklyConfiguration { + private static final Logger log = LoggerFactory.getLogger(LaunchDarklyConfiguration.class); + + @Bean(destroyMethod = "close") + public LDClient ldClient( + @Value("${LAUNCHDARKLY_SDK_KEY:}") String sdkKey, + @Value("${OTEL_EXPORTER_OTLP_ENDPOINT:}") String otlpEndpoint, + @Value("${LD_OBSERVABILITY_BACKEND_URL:}") String backendUrl, + @Value("${HTTPS_PROXY:${https_proxy:}}") String httpsProxy, + @Value("${SERVICE_VERSION:dev}") String serviceVersion) { + ObservabilityOptions.Builder options = new ObservabilityOptions.Builder() + .serviceName("java-plugin-spring-boot-example") + .serviceVersion(serviceVersion) + .environment("test") + .resourceAttributes(Attributes.of( + AttributeKey.stringKey("example.framework"), "spring-boot", + AttributeKey.stringKey("example.runtime"), System.getProperty("java.version"))) + .logsApiLevel(ObservabilityOptions.LogLevel.INFO); + if (!otlpEndpoint.isEmpty()) { + options.otlpEndpoint(otlpEndpoint); + } + if (!backendUrl.isEmpty()) { + options.backendUrl(backendUrl); + } + if (!httpsProxy.isEmpty()) { + String hostPort = httpsProxy.replaceFirst("^[a-z]+://", "").replaceAll("/.*$", ""); + int colon = hostPort.lastIndexOf(':'); + options.proxyHostAndPort(hostPort.substring(0, colon), Integer.parseInt(hostPort.substring(colon + 1))); + } + + boolean offline = sdkKey.isEmpty(); + LDConfig config = new LDConfig.Builder() + .offline(offline) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options.build())))) + .build(); + LDClient client = new LDClient(offline ? "offline" : sdkKey, config); + log.info("LaunchDarkly client initialized={}{}", client.isInitialized(), + offline ? " (offline: LAUNCHDARKLY_SDK_KEY not set)" : ""); + return client; + } + + /** Flushes and stops the OpenTelemetry providers after the client bean closes. */ + @Bean + public ObservabilityShutdown observabilityShutdown() { + return new ObservabilityShutdown(); + } + + static final class ObservabilityShutdown implements AutoCloseable { + @Override + public void close() { + LDObserve.shutdown(); + } + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/RequestTelemetryFilter.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/RequestTelemetryFilter.java new file mode 100644 index 0000000000..19b7646996 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/RequestTelemetryFilter.java @@ -0,0 +1,59 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.observability.LDObserve; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * One span, a duration histogram, a request counter and a log line per HTTP + * request. Controllers run inside the span, so flag evaluations and errors + * recorded there are children of it. + */ +@Component +public class RequestTelemetryFilter extends OncePerRequestFilter { + static final AttributeKey ROUTE = AttributeKey.stringKey("http.route"); + static final AttributeKey METHOD = AttributeKey.stringKey("http.request.method"); + static final AttributeKey STATUS = AttributeKey.longKey("http.response.status_code"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String route = request.getRequestURI(); + long started = System.nanoTime(); + Span span = LDObserve.startSpan(request.getMethod() + " " + route, + Attributes.of(ROUTE, route, METHOD, request.getMethod())); + try (Scope ignored = span.makeCurrent()) { + chain.doFilter(request, response); + } catch (Exception e) { + span.setStatus(StatusCode.ERROR, e.getMessage()); + throw e; + } finally { + int status = response.getStatus(); + if (status >= 500) { + span.setStatus(StatusCode.ERROR); + } + span.setAttribute(STATUS, (long) status); + span.end(); + + double millis = TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - started) / 1000.0; + Attributes attrs = Attributes.of(ROUTE, route, STATUS, (long) status); + LDObserve.recordHistogram("http.server.request.duration_ms", millis, attrs); + LDObserve.recordIncr("http.server.requests", attrs); + LDObserve.recordLog(request.getMethod() + " " + route + " -> " + status, + status >= 500 ? Severity.ERROR : Severity.INFO, attrs); + } + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/resources/application.properties b/e2e/java-plugin-spring-boot/src/main/resources/application.properties new file mode 100644 index 0000000000..e71dddaa4e --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/resources/application.properties @@ -0,0 +1,8 @@ +spring.application.name=java-plugin-spring-boot-example +server.port=${PORT:8080} +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=15s +management.endpoints.web.exposure.include=health +# Let the controller advice see unknown paths as 404s instead of the whitelabel page. +spring.web.resources.add-mappings=false +spring.mvc.throw-exception-if-no-handler-found=true diff --git a/e2e/java-plugin/Dockerfile b/e2e/java-plugin/Dockerfile deleted file mode 100644 index b71bd038a2..0000000000 --- a/e2e/java-plugin/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -# Build context is the repository root: -# docker build -f e2e/java-plugin/Dockerfile -t e2e-java-plugin . -# The plugin is built from source, so the image never needs a published artifact. -FROM eclipse-temurin:21-jdk AS build -WORKDIR /src -COPY sdk/@launchdarkly/observability-java sdk/@launchdarkly/observability-java -COPY e2e/java-plugin e2e/java-plugin -WORKDIR /src/e2e/java-plugin -RUN ../../sdk/@launchdarkly/observability-java/gradlew -p . installDist --no-daemon --console=plain \ - -PmainClass=com.launchdarkly.observability.example.ServerExample - -FROM eclipse-temurin:21-jre -WORKDIR /app -COPY --from=build /src/e2e/java-plugin/build/install/java-plugin-example /app -ENV PORT=8080 -ENV BACKEND_URI="http://localhost:8080/" -EXPOSE 8080 -CMD ["/app/bin/java-plugin-example"] diff --git a/e2e/java-plugin/Makefile b/e2e/java-plugin/Makefile index 20245a31be..2cf591dd2c 100644 --- a/e2e/java-plugin/Makefile +++ b/e2e/java-plugin/Makefile @@ -1,4 +1,4 @@ -.PHONY: run check serve consumer-check +.PHONY: run check consumer-check SDK_DIR := ../../sdk/@launchdarkly/observability-java SDK_GRADLE := $(SDK_DIR)/gradlew @@ -8,11 +8,6 @@ GRADLE_ARGS := --no-daemon --console=plain run check: $(SDK_GRADLE) -p $(CURDIR) run $(GRADLE_ARGS) -# HTTP server example. Honours LAUNCHDARKLY_SDK_KEY, PORT, -# OTEL_EXPORTER_OTLP_ENDPOINT and LD_OBSERVABILITY_BACKEND_URL. -serve: - $(SDK_GRADLE) -p $(CURDIR) run $(GRADLE_ARGS) -PmainClass=com.launchdarkly.observability.example.ServerExample - # Publishes the plugin to ~/.m2 and builds this example against that # artifact, the way an application would. Catches POM scope mistakes and # transitive dependency leaks that the composite build hides. diff --git a/e2e/java-plugin/README.md b/e2e/java-plugin/README.md index 97f62a336c..c120717315 100644 --- a/e2e/java-plugin/README.md +++ b/e2e/java-plugin/README.md @@ -1,64 +1,35 @@ -# Java observability plugin examples +# Java observability plugin self-check -Two runnable programs for the LaunchDarkly Java server observability plugin. -Both build against the plugin sources in this repository by default, so they -never need a published artifact. - -## Self-check (`make run`) - -`PluginExample` starts a local OTLP and sampling endpoint, registers +`PluginExample` is a runnable check for the LaunchDarkly Java server +observability plugin. It starts a local OTLP and sampling endpoint, registers `ObservabilityPlugin` on an offline Java server SDK client, and exits non-zero -unless the collector saw the expected signals. A passing run prints one -`RESULT` line. `checkout-enabled` is the flag-evaluation span from -`TracingHook`. `kept-checkout` is kept by the sampling rule and carries -`launchdarkly.sampling.ratio`. `health-check` is dropped. A call to -`LDObserve` before the client starts must not install the OpenTelemetry no-op -global; the flag span only arrives when the hook is on the plugin's exporters. - -```bash -make run -``` +unless the collector saw the expected signals. It builds against the plugin +sources in this repository by default, so it never needs a published artifact. -## HTTP server (`make serve`) +For a web backend that uses the plugin the way a service does, see +[`../java-plugin-spring-boot`](../java-plugin-spring-boot). -`ServerExample` is a plain `com.sun.net.httpserver` service on port 8080 with -`/`, `/rolldice` and `/error`. Each request is a span, evaluates a flag (which -the plugin records on the span), records a histogram, a counter and a log; the -`/error` route records an exception. +## Run ```bash -export LAUNCHDARKLY_SDK_KEY="key-from-your-project" # unset = offline client -make serve & -until curl -s -o /dev/null localhost:8080/; do sleep 1; done -curl -s localhost:8080/rolldice -curl -s localhost:8080/error +make run ``` -Environment the server honours: - -| Variable | Purpose | -|----------|---------| -| `LAUNCHDARKLY_SDK_KEY` | SDK key. Unset runs the client offline. | -| `PORT` | Listen port, default `8080`. | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP base URL, e.g. `http://localhost:4318` for a local stack. | -| `LD_OBSERVABILITY_BACKEND_URL` | Sampling-config URL for a local stack. | -| `HTTPS_PROXY` | `host:port` of an egress proxy; applied to exports and the sampling request. | +A passing run prints one `RESULT` line. `checkout-enabled` is the +flag-evaluation span from `TracingHook`. `kept-checkout` is kept by the +sampling rule and carries `launchdarkly.sampling.ratio`. `health-check` is +dropped. A call to `LDObserve` before the client starts must not install the +OpenTelemetry no-op global; the flag span only arrives when the hook is on the +plugin's exporters. -### Docker - -The image is built from the repository root so the plugin can be compiled from -source. It is also wired into `e2e/compose.yml` as the `java` service, which -the dockerized e2e workflow starts and health-checks. +## Consumer check ```bash -docker build -f e2e/java-plugin/Dockerfile -t e2e-java-plugin . -docker run --rm -p 8080:8080 -e LAUNCHDARKLY_SDK_KEY e2e-java-plugin +make consumer-check ``` -## Consumer check (`make consumer-check`) - Publishes the plugin to `~/.m2` and builds the example against that artifact with `-PuseMavenLocal`, the way an application would. This verifies the published POM: the example compiles against `Attributes` and `Severity` without declaring `opentelemetry-api` itself, and no OkHttp, Okio or Kotlin arrives -transitively. CI runs this on every change to the plugin. +transitively. CI runs this on JDK 11 and 21 for every change to the plugin. diff --git a/e2e/java-plugin/build.gradle b/e2e/java-plugin/build.gradle index 34a4de7910..d272e88ac3 100644 --- a/e2e/java-plugin/build.gradle +++ b/e2e/java-plugin/build.gradle @@ -30,7 +30,7 @@ dependencies { } application { - mainClass = project.findProperty('mainClass') ?: 'com.launchdarkly.observability.example.PluginExample' + mainClass = 'com.launchdarkly.observability.example.PluginExample' } // Groups that must never reach a consumer's runtime classpath through the plugin. diff --git a/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/ServerExample.java b/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/ServerExample.java deleted file mode 100644 index d7035b9db4..0000000000 --- a/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/ServerExample.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.launchdarkly.observability.example; - -import com.launchdarkly.observability.LDObserve; -import com.launchdarkly.observability.ObservabilityOptions; -import com.launchdarkly.observability.ObservabilityPlugin; -import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDConfig; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import io.opentelemetry.api.common.AttributeKey; -import io.opentelemetry.api.common.Attributes; -import io.opentelemetry.api.logs.Severity; -import io.opentelemetry.api.trace.Span; -import io.opentelemetry.api.trace.StatusCode; -import io.opentelemetry.context.Scope; - -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; - -/** - * A long-running HTTP server that uses the LaunchDarkly Java Server SDK with - * the observability plugin, the way a service would. Every request is traced, - * evaluates a flag (which the plugin records as a span event), records a - * metric and a log, and some routes record an error. - * - *

Environment:

- *
    - *
  • {@code LAUNCHDARKLY_SDK_KEY}: the SDK key. When unset the client runs - * offline, so the server still works without LaunchDarkly access.
  • - *
  • {@code PORT}: listen port, default 8080.
  • - *
  • {@code OTEL_EXPORTER_OTLP_ENDPOINT}: OTLP/HTTP base URL. Defaults to - * the LaunchDarkly endpoint.
  • - *
  • {@code LD_OBSERVABILITY_BACKEND_URL}: sampling-config URL. Defaults to - * the LaunchDarkly backend.
  • - *
  • {@code HTTPS_PROXY} / {@code https_proxy}: {@code host:port} of an - * egress proxy for exports and the sampling request.
  • - *
- */ -public final class ServerExample { - private static final AttributeKey ROUTE = AttributeKey.stringKey("http.route"); - private static final AttributeKey STATUS = AttributeKey.longKey("http.response.status_code"); - - public static void main(String[] args) throws Exception { - String sdkKey = env("LAUNCHDARKLY_SDK_KEY", ""); - int port = Integer.parseInt(env("PORT", "8080")); - - ObservabilityOptions.Builder options = new ObservabilityOptions.Builder() - .serviceName("java-plugin-example") - .serviceVersion(env("SERVICE_VERSION", "dev")) - .environment("test") - .resourceAttributes(Attributes.of(AttributeKey.stringKey("example.runtime"), System.getProperty("java.version"))) - .logsApiLevel(ObservabilityOptions.LogLevel.INFO); - String otlp = env("OTEL_EXPORTER_OTLP_ENDPOINT", ""); - if (!otlp.isEmpty()) { - options.otlpEndpoint(otlp); - } - String backend = env("LD_OBSERVABILITY_BACKEND_URL", ""); - if (!backend.isEmpty()) { - options.backendUrl(backend); - } - String proxy = env("HTTPS_PROXY", env("https_proxy", "")); - if (!proxy.isEmpty()) { - String hostPort = proxy.replaceFirst("^[a-z]+://", "").replaceAll("/.*$", ""); - int colon = hostPort.lastIndexOf(':'); - options.proxyHostAndPort(hostPort.substring(0, colon), Integer.parseInt(hostPort.substring(colon + 1))); - } - - LDConfig config = new LDConfig.Builder() - .offline(sdkKey.isEmpty()) - .startWait(Duration.ofSeconds(5)) - .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options.build())))) - .build(); - LDClient client = new LDClient(sdkKey.isEmpty() ? "offline" : sdkKey, config); - System.out.println("LaunchDarkly client initialized=" + client.isInitialized() - + (sdkKey.isEmpty() ? " (offline: LAUNCHDARKLY_SDK_KEY not set)" : "")); - - HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); - server.setExecutor(Executors.newFixedThreadPool(8)); - server.createContext("/", exchange -> handle(exchange, client, "/", ServerExample::index)); - server.createContext("/rolldice", exchange -> handle(exchange, client, "/rolldice", ServerExample::rollDice)); - server.createContext("/error", exchange -> handle(exchange, client, "/error", ServerExample::error)); - server.start(); - System.out.println("Listening on http://0.0.0.0:" + port + " (routes: /, /rolldice, /error)"); - - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - server.stop(1); - try { - client.close(); - } catch (IOException ignored) { - // Nothing left to do on shutdown. - } - LDObserve.shutdown(); - }, "example-shutdown")); - - Thread.currentThread().join(); - } - - @FunctionalInterface - private interface Route { - String apply(LDClient client, LDContext context) throws Exception; - } - - private static void handle(HttpExchange exchange, LDClient client, String route, Route body) throws IOException { - long started = System.nanoTime(); - Span span = LDObserve.startSpan("HTTP " + exchange.getRequestMethod() + " " + route, - Attributes.of(ROUTE, route)); - int status = 200; - String response; - try (Scope ignored = span.makeCurrent()) { - LDContext context = LDContext.builder("user-" + ThreadLocalRandom.current().nextInt(1000)).build(); - response = body.apply(client, context); - } catch (Exception e) { - status = 500; - response = "error: " + e.getMessage() + "\n"; - span.setStatus(StatusCode.ERROR, e.getMessage()); - LDObserve.recordError(e, Attributes.of(ROUTE, route)); - } finally { - span.setAttribute(STATUS, (long) status); - span.end(); - } - double millis = TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - started) / 1000.0; - LDObserve.recordHistogram("http.server.duration_ms", millis, Attributes.of(ROUTE, route)); - LDObserve.recordIncr("http.server.requests", Attributes.of(ROUTE, route, STATUS, (long) status)); - LDObserve.recordLog(exchange.getRequestMethod() + " " + route + " -> " + status, - status >= 500 ? Severity.ERROR : Severity.INFO, Attributes.of(ROUTE, route)); - - byte[] bytes = response.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(status, bytes.length); - try (OutputStream out = exchange.getResponseBody()) { - out.write(bytes); - } - } - - private static String index(LDClient client, LDContext context) { - boolean enabled = client.boolVariation("example-greeting", context, false); - return (enabled ? "Hello from the flagged path" : "Hello, World!") + "\n"; - } - - private static String rollDice(LDClient client, LDContext context) { - int sides = (int) client.intVariation("dice-sides", context, 6); - int roll = ThreadLocalRandom.current().nextInt(1, sides + 1); - Span.current().setAttribute("dice.roll", roll); - return roll + "\n"; - } - - private static String error(LDClient client, LDContext context) { - boolean soft = client.boolVariation("soft-errors", context, false); - if (soft) { - return "soft error recorded\n"; - } - throw new IllegalStateException("random error from the Java plugin example"); - } - - private static String env(String name, String fallback) { - String value = System.getenv(name); - return value == null || value.isEmpty() ? fallback : value; - } -} From 6ca7fc5e10502d55b574ffeaf3341f610e956084 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 20:29:44 +0000 Subject: [PATCH 12/17] ci: name the dockerized Java e2e service after its directory app_runner.py asserts that e2e/ exists before building the compose service, so the service, image and matrix entry are java-plugin-spring-boot rather than java. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- .github/workflows/e2e.yml | 2 +- e2e/compose.yml | 7 ++++--- e2e/java-plugin-spring-boot/README.md | 2 +- e2e/tests/src/app_runner.py | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 51394ffe82..db07120c2d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-22.04-8core-32gb strategy: matrix: - app: ['dotnet', 'dotnet4', 'go', 'java', 'python', 'ruby'] + app: ['dotnet', 'dotnet4', 'go', 'java-plugin-spring-boot', 'python', 'ruby'] steps: - uses: actions/checkout@v4 diff --git a/e2e/compose.yml b/e2e/compose.yml index 486e4b8060..692ab10140 100644 --- a/e2e/compose.yml +++ b/e2e/compose.yml @@ -48,10 +48,11 @@ services: args: IMAGE_NAME_BASE: ${IMAGE_NAME_BASE-ghcr.io/highlight/e2e:latest} container_name: ruby - java: - image: ${IMAGE_NAME-ghcr.io/highlight/e2e-java:latest} + # Service name matches the e2e/ directory, which app_runner.py requires. + java-plugin-spring-boot: + image: ${IMAGE_NAME-ghcr.io/highlight/e2e-java-plugin-spring-boot:latest} build: # The plugin is compiled from source, so the context is the repo root. context: .. dockerfile: e2e/java-plugin-spring-boot/Dockerfile - container_name: java + container_name: java-plugin-spring-boot diff --git a/e2e/java-plugin-spring-boot/README.md b/e2e/java-plugin-spring-boot/README.md index 4bbd67275f..540fa7196a 100644 --- a/e2e/java-plugin-spring-boot/README.md +++ b/e2e/java-plugin-spring-boot/README.md @@ -51,7 +51,7 @@ Kotlin onto the application's classpath. ## Docker The image is built from the repository root so the plugin can be compiled -from source. It is the `java` service in `e2e/compose.yml`, which the +from source. It is the `java-plugin-spring-boot` service in `e2e/compose.yml`, which the dockerized e2e workflow builds, starts and health-checks. ```bash diff --git a/e2e/tests/src/app_runner.py b/e2e/tests/src/app_runner.py index a9dd6b472d..8185498ef8 100644 --- a/e2e/tests/src/app_runner.py +++ b/e2e/tests/src/app_runner.py @@ -179,7 +179,7 @@ def main(): "dotnet", "dotnet4", "go", - "java", + "java-plugin-spring-boot", "python", "ruby", ], From 331597ae16d9e46173f51be3017c3c253c8b0eba Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 20:44:45 +0000 Subject: [PATCH 13/17] ci: format the e2e workflow matrix the way prettier wants it Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- .github/workflows/e2e.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index db07120c2d..29a1425108 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,7 +14,15 @@ jobs: runs-on: ubuntu-22.04-8core-32gb strategy: matrix: - app: ['dotnet', 'dotnet4', 'go', 'java-plugin-spring-boot', 'python', 'ruby'] + app: + [ + 'dotnet', + 'dotnet4', + 'go', + 'java-plugin-spring-boot', + 'python', + 'ruby', + ] steps: - uses: actions/checkout@v4 From 8b258def745f5e74767acf65480990636990dd4c Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 21:15:10 +0000 Subject: [PATCH 14/17] fix: tolerate JSON nulls in the sampling payload and export log severity text GraphQL returns null for optional fields that are unset, and Gson's getAsJsonObject/getAsJsonArray throw on JsonNull, so one null field aborted the whole sampling payload; the exception was swallowed and sampling silently never applied. Every lookup now treats null like absent and a null samplingRatio defaults to 1. LDObserve.recordLog set only the numeric severity. Severity number and text are separate OTLP fields, and the backend UI and severityText sampling rules read the text, so it is set to the enum name, as the Android emitter does. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- .../launchdarkly/observability/LDObserve.java | 3 + .../internal/SamplingApiService.java | 70 ++++++++++++++----- .../internal/GraphQLClientTest.java | 23 ++++++ .../internal/PluginOptionsFlowTest.java | 2 + 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java index 143d58241c..93be5a623f 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java @@ -74,8 +74,11 @@ public static void recordUpDownCounter(String name, long value, Attributes attri */ public static void recordLog(String message, Severity severity, Attributes attributes) { Logger logger = OtelManager.getLogger(); + // Severity number and text are separate OTLP fields; the backend UI + // and severityText sampling rules read the text. logger.logRecordBuilder() .setSeverity(severity) + .setSeverityText(severity.name()) .setBody(message) .setAllAttributes(attributes) .emit(); diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java index dc069a9550..0b5dc20475 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java @@ -106,7 +106,7 @@ public SamplingConfig getSamplingConfig(String sdkKey) { if (data == null || !data.isJsonObject()) { return null; } - JsonObject sampling = data.getAsJsonObject().getAsJsonObject("sampling"); + JsonObject sampling = objectOrNull(data.getAsJsonObject(), "sampling"); if (sampling == null) { return null; } @@ -121,17 +121,21 @@ private SamplingConfig parseSamplingConfig(JsonObject sampling) { List spans = new ArrayList<>(); List logs = new ArrayList<>(); - JsonArray spansArray = sampling.getAsJsonArray("spans"); + JsonArray spansArray = arrayOrNull(sampling, "spans"); if (spansArray != null) { for (JsonElement el : spansArray) { - spans.add(parseSpanConfig(el.getAsJsonObject())); + if (el.isJsonObject()) { + spans.add(parseSpanConfig(el.getAsJsonObject())); + } } } - JsonArray logsArray = sampling.getAsJsonArray("logs"); + JsonArray logsArray = arrayOrNull(sampling, "logs"); if (logsArray != null) { for (JsonElement el : logsArray) { - logs.add(parseLogConfig(el.getAsJsonObject())); + if (el.isJsonObject()) { + logs.add(parseLogConfig(el.getAsJsonObject())); + } } } @@ -139,46 +143,74 @@ private SamplingConfig parseSamplingConfig(JsonObject sampling) { } private SpanSamplingConfig parseSpanConfig(JsonObject obj) { - MatchConfig name = parseMatchConfig(obj.getAsJsonObject("name")); - List attributes = parseAttributeConfigs(obj.getAsJsonArray("attributes")); + MatchConfig name = parseMatchConfig(objectOrNull(obj, "name")); + List attributes = parseAttributeConfigs(arrayOrNull(obj, "attributes")); List events = new ArrayList<>(); - JsonArray eventsArray = obj.getAsJsonArray("events"); + JsonArray eventsArray = arrayOrNull(obj, "events"); if (eventsArray != null) { for (JsonElement el : eventsArray) { + if (!el.isJsonObject()) { + continue; + } JsonObject eventObj = el.getAsJsonObject(); events.add(new SpanEventMatchConfig( - parseMatchConfig(eventObj.getAsJsonObject("name")), - parseAttributeConfigs(eventObj.getAsJsonArray("attributes")) + parseMatchConfig(objectOrNull(eventObj, "name")), + parseAttributeConfigs(arrayOrNull(eventObj, "attributes")) )); } } - int ratio = obj.has("samplingRatio") ? obj.get("samplingRatio").getAsInt() : 1; - return new SpanSamplingConfig(name, attributes, events, ratio); + return new SpanSamplingConfig(name, attributes, events, ratioOrDefault(obj)); } private LogSamplingConfig parseLogConfig(JsonObject obj) { - MatchConfig message = parseMatchConfig(obj.getAsJsonObject("message")); - MatchConfig severityText = parseMatchConfig(obj.getAsJsonObject("severityText")); - List attributes = parseAttributeConfigs(obj.getAsJsonArray("attributes")); - int ratio = obj.has("samplingRatio") ? obj.get("samplingRatio").getAsInt() : 1; - return new LogSamplingConfig(message, severityText, attributes, ratio); + MatchConfig message = parseMatchConfig(objectOrNull(obj, "message")); + MatchConfig severityText = parseMatchConfig(objectOrNull(obj, "severityText")); + List attributes = parseAttributeConfigs(arrayOrNull(obj, "attributes")); + return new LogSamplingConfig(message, severityText, attributes, ratioOrDefault(obj)); } private List parseAttributeConfigs(JsonArray array) { if (array == null) return Collections.emptyList(); List result = new ArrayList<>(); for (JsonElement el : array) { + if (!el.isJsonObject()) { + continue; + } JsonObject obj = el.getAsJsonObject(); result.add(new AttributeMatchConfig( - parseMatchConfig(obj.getAsJsonObject("key")), - parseMatchConfig(obj.getAsJsonObject("attribute")) + parseMatchConfig(objectOrNull(obj, "key")), + parseMatchConfig(objectOrNull(obj, "attribute")) )); } return result; } + /** + * GraphQL returns {@code null} for optional fields that are unset, and + * Gson's {@code getAsJsonObject} / {@code getAsJsonArray} throw on + * {@code JsonNull}. One null field must not abort the whole payload, so + * every lookup goes through these and treats null like absent. + */ + private static JsonObject objectOrNull(JsonObject parent, String key) { + JsonElement el = parent.get(key); + return el != null && el.isJsonObject() ? el.getAsJsonObject() : null; + } + + private static JsonArray arrayOrNull(JsonObject parent, String key) { + JsonElement el = parent.get(key); + return el != null && el.isJsonArray() ? el.getAsJsonArray() : null; + } + + private static int ratioOrDefault(JsonObject obj) { + JsonElement el = obj.get("samplingRatio"); + if (el == null || !el.isJsonPrimitive() || !el.getAsJsonPrimitive().isNumber()) { + return 1; + } + return el.getAsInt(); + } + private MatchConfig parseMatchConfig(JsonObject obj) { if (obj == null) return null; if (obj.has("regexValue") && !obj.get("regexValue").isJsonNull()) { diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java index 8688c4a545..493ffee72b 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java @@ -74,6 +74,29 @@ void samplingQueryIsACompileTimeConstantAndIsSentWithVariables() throws Exceptio assertEquals("", headerValues.get(0), "no custom header was configured"); } + @Test + void samplingPayloadWithJsonNullsStillParses() throws Exception { + // GraphQL returns null for unset optional fields. A single null must not + // abort the payload, or sampling silently never applies. + response = "{\"data\":{\"sampling\":{\"spans\":[" + + "{\"name\":null,\"attributes\":null,\"events\":null,\"samplingRatio\":null}," + + "{\"name\":{\"matchValue\":\"health-check\",\"regexValue\":null}," + + "\"attributes\":[{\"key\":{\"matchValue\":\"k\"},\"attribute\":null}]," + + "\"events\":[{\"name\":null,\"attributes\":null}],\"samplingRatio\":0}" + + "],\"logs\":[{\"message\":null,\"severityText\":{\"matchValue\":\"ERROR\"}," + + "\"attributes\":null,\"samplingRatio\":3}]}}}"; + GraphQLClient client = new GraphQLClient(url("/graphql")); + + SamplingConfig config = new SamplingApiService(client).getSamplingConfig("sdk-test"); + + assertNotNull(config, "nulls in optional fields must not abort parsing"); + assertEquals(2, config.getSpans().size()); + assertEquals(1, config.getSpans().get(0).getSamplingRatio(), "null ratio defaults to 1"); + assertEquals(0, config.getSpans().get(1).getSamplingRatio()); + assertEquals(1, config.getLogs().size()); + assertEquals(3, config.getLogs().get(0).getSamplingRatio()); + } + @Test void customHeadersAreSentWithEveryRequest() throws Exception { GraphQLClient client = new GraphQLClient(url("/graphql"), Map.of("X-Test-Header", "abc"), null); diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java index 5ea1db158e..278df195ab 100644 --- a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java @@ -153,6 +153,8 @@ void logsBelowConfiguredLevelAreNotExported() throws Exception { assertTrue(waitFor("/v1/logs"), "no log export"); assertTrue(anyBodyContains("/v1/logs", "warn-signal")); assertTrue(anyBodyContains("/v1/logs", "error-signal")); + assertTrue(anyBodyContains("/v1/logs", "WARN"), "severity text missing on exported log"); + assertTrue(anyBodyContains("/v1/logs", "ERROR"), "severity text missing on exported log"); assertFalse(anyBodyContains("/v1/logs", "debug-noise"), "DEBUG record exported below WARN threshold"); assertFalse(anyBodyContains("/v1/logs", "info-noise"), "INFO record exported below WARN threshold"); } From c09b2a78037789a9f0950eb8ef88331409d801be Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 21:15:10 +0000 Subject: [PATCH 15/17] ci: suppress DevSkim loopback findings on the smoke test and e2e Dockerfile The smoke test reaches the app it just started on the same runner, and app_runner.py reads BACKEND_URI from the container the way the other e2e apps set it. Both are loopback by design, which DS162092 flags as debug code; the repo's convention is to mark these as used in tests. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- .github/workflows/java-plugin-e2e.yml | 16 ++++++++++------ e2e/java-plugin-spring-boot/Dockerfile | 3 +++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/java-plugin-e2e.yml b/.github/workflows/java-plugin-e2e.yml index 7a30fadd48..75b4b60c7f 100644 --- a/.github/workflows/java-plugin-e2e.yml +++ b/.github/workflows/java-plugin-e2e.yml @@ -52,19 +52,23 @@ jobs: - name: Smoke-test the Spring Boot backend working-directory: ./e2e/java-plugin-spring-boot + # The app under test runs on this runner, so it is reached on the + # loopback address. DevSkim DS162092 flags that as debug code. + env: + APP: http://localhost:8080 # DevSkim: ignore DS162092 run: | java -jar build/libs/*.jar > app.log 2>&1 & APP_PID=$! for i in $(seq 1 120); do - if curl -sf -o /dev/null localhost:8080/; then break; fi + if curl -sf -o /dev/null "$APP/"; then break; fi if ! kill -0 "$APP_PID" 2>/dev/null; then echo "app exited early"; cat app.log; exit 1; fi sleep 1 done - curl -sf localhost:8080/ || { cat app.log; exit 1; } - curl -sf -H 'X-User-Key: ci' localhost:8080/rolldice || { cat app.log; exit 1; } - curl -sf localhost:8080/flags/example-greeting || { cat app.log; exit 1; } - curl -sf localhost:8080/actuator/health || { cat app.log; exit 1; } - code=$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/error) + curl -sf "$APP/" || { cat app.log; exit 1; } + curl -sf -H 'X-User-Key: ci' "$APP/rolldice" || { cat app.log; exit 1; } + curl -sf "$APP/flags/example-greeting" || { cat app.log; exit 1; } + curl -sf "$APP/actuator/health" || { cat app.log; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' "$APP/error") [ "$code" = "500" ] || { echo "expected 500 from /error, got $code"; cat app.log; exit 1; } kill -TERM "$APP_PID" wait "$APP_PID" || true diff --git a/e2e/java-plugin-spring-boot/Dockerfile b/e2e/java-plugin-spring-boot/Dockerfile index 22328acc66..5be3a59526 100644 --- a/e2e/java-plugin-spring-boot/Dockerfile +++ b/e2e/java-plugin-spring-boot/Dockerfile @@ -13,6 +13,9 @@ FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=build /src/app.jar /app/app.jar ENV PORT=8080 +# e2e/tests/src/app_runner.py reads BACKEND_URI from the container to find the +# port to publish and the URL to health-check, as the other e2e apps do. +# DevSkim: ignore DS162092 ENV BACKEND_URI="http://localhost:8080/" EXPOSE 8080 CMD ["java", "-jar", "/app/app.jar"] From 9898a2c067702c62e78366499fc7c412e584d96e Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 21:28:55 +0000 Subject: [PATCH 16/17] ci: explain the dismissed DevSkim finding on the e2e Dockerfile A DevSkim suppression on the line above an ENV instruction has no effect, so the alert is dismissed as used in tests instead, as for the other e2e Dockerfiles. The comment says so. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- e2e/java-plugin-spring-boot/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/java-plugin-spring-boot/Dockerfile b/e2e/java-plugin-spring-boot/Dockerfile index 5be3a59526..ca3aa92dbc 100644 --- a/e2e/java-plugin-spring-boot/Dockerfile +++ b/e2e/java-plugin-spring-boot/Dockerfile @@ -15,7 +15,9 @@ COPY --from=build /src/app.jar /app/app.jar ENV PORT=8080 # e2e/tests/src/app_runner.py reads BACKEND_URI from the container to find the # port to publish and the URL to health-check, as the other e2e apps do. -# DevSkim: ignore DS162092 +# (DevSkim DS162092 flags the loopback literal; it is dismissed as used in +# tests, like the other e2e Dockerfiles, because ENV lines take no inline +# suppression comment.) ENV BACKEND_URI="http://localhost:8080/" EXPOSE 8080 CMD ["java", "-jar", "/app/app.jar"] From 2f3acf0d5c0a31e2cf1926e384b413102765489e Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Mon, 19 Oct 2026 21:41:36 +0000 Subject: [PATCH 17/17] ci: publish the Java plugin to Maven Central on release release-please already versioned the component but no job published it, so a release would have produced a tag and nothing on Maven Central. Adds a publish-java-sdk composite action modelled on the Android one (same Sonatype Central account and code-signing key, no Android SDK setup), a release-java-plugin job and outputs in release-please.yml, and a Java toggle in the manual publish workflow with dry-run. publish.sh runs :lib:build before publishToSonatype so the unit tests, the runtime-classpath guard and the POM guard gate every upload; the manual dry run does the same build without publishing. Co-Authored-By: Claude Fable 5.1 Generated-by: termag --- .github/actions/publish-java-sdk/action.yml | 76 +++++++++++++++++++++ .github/actions/publish-java-sdk/publish.sh | 37 ++++++++++ .github/workflows/manual-publish.yml | 22 ++++++ .github/workflows/release-please.yml | 20 ++++++ 4 files changed, 155 insertions(+) create mode 100644 .github/actions/publish-java-sdk/action.yml create mode 100755 .github/actions/publish-java-sdk/publish.sh diff --git a/.github/actions/publish-java-sdk/action.yml b/.github/actions/publish-java-sdk/action.yml new file mode 100644 index 0000000000..e0aa139c3d --- /dev/null +++ b/.github/actions/publish-java-sdk/action.yml @@ -0,0 +1,76 @@ +name: 'Publish Java SDK' +description: 'Build and publish the Java server observability plugin to Maven Central' + +inputs: + workspace-path: + description: 'Path to the Java SDK workspace' + required: true + default: 'sdk/@launchdarkly/observability-java' + java_version: + description: 'The Java version to use.' + required: false + default: '17' + java_distribution: + description: 'The Java distribution to use.' + required: false + default: 'temurin' + aws-role-arn: + description: 'AWS role ARN for accessing secrets' + required: true + dry-run: + description: 'Whether to run the publish in dry-run mode' + required: false + default: 'false' + prerelease: + description: 'Whether to publish a prerelease version' + required: false + default: 'false' + +runs: + using: 'composite' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: ${{ inputs.java_distribution }} + java-version: ${{ inputs.java_version }} + + # Same Sonatype Central account and code-signing key as the Android + # plugin; both publish under the com.launchdarkly namespace. + - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 + name: Get secrets + with: + aws_assume_role: ${{ inputs.aws-role-arn }} + ssm_parameter_pairs: | + /production/common/releasing/sonatype/central/username = SONATYPE_USER_NAME, + /production/common/releasing/sonatype/central/password = SONATYPE_PASSWORD, + /production/common/releasing/android_code_signing/private_key_id = SIGNING_KEY_ID, + /production/common/releasing/android_code_signing/private_key_passphrase = SIGNING_KEY_PASSPHRASE + s3_path_pairs: 'launchdarkly-releaser/android/code-signing-keyring.gpg = code-signing-keyring.gpg' + + - name: Publish Library + shell: bash + if: ${{ inputs.dry-run != 'true' }} + working-directory: ${{ inputs.workspace-path }} + env: + LD_RELEASE_IS_PRERELEASE: ${{ inputs.prerelease }} + SIGNING_KEY_ID: ${{ env.SIGNING_KEY_ID }} + SIGNING_KEY_PASSPHRASE: ${{ env.SIGNING_KEY_PASSPHRASE }} + SIGNING_SECRET_KEY_RING_FILE: ${{ github.workspace }}/code-signing-keyring.gpg + SONATYPE_USER_NAME: ${{ env.SONATYPE_USER_NAME }} + SONATYPE_PASSWORD: ${{ env.SONATYPE_PASSWORD }} + run: source $GITHUB_ACTION_PATH/publish.sh + + # A dry run still builds and verifies the artifact (unit tests, the + # runtime-classpath guard and the POM guard) so a broken release is + # caught before anyone flips dry-run off. + - name: Dry Run Publish Library + shell: bash + if: ${{ inputs.dry-run == 'true' }} + working-directory: ${{ inputs.workspace-path }} + run: | + ./gradlew :lib:build --no-daemon --console=plain + echo "Dry run. Not publishing." diff --git a/.github/actions/publish-java-sdk/publish.sh b/.github/actions/publish-java-sdk/publish.sh new file mode 100755 index 0000000000..679fa33957 --- /dev/null +++ b/.github/actions/publish-java-sdk/publish.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +set -ue + +GRADLE_ARGS=( + --no-daemon + --console=plain + -Psigning.keyId="${SIGNING_KEY_ID}" + -Psigning.password="${SIGNING_KEY_PASSPHRASE}" + -Psigning.secretKeyRingFile="${SIGNING_SECRET_KEY_RING_FILE}" + -PsonatypeUsername="${SONATYPE_USER_NAME}" + -PsonatypePassword="${SONATYPE_PASSWORD}" +) + +# `check` runs the unit tests plus the guards on what gets published: no +# OkHttp/Okio/Kotlin on the runtime classpath, and opentelemetry-api at +# compile scope in the POM. Nothing is uploaded if any of them fail. +echo "Verifying the artifact" +./gradlew :lib:build "${GRADLE_ARGS[@]}" || { + echo "Gradle build/verification failed" >&2 + exit 1 +} + +echo "Publishing to Sonatype" +if [ "${LD_RELEASE_IS_PRERELEASE}" == "true" ]; then + echo "PRERELEASE" + ./gradlew publishToSonatype "${GRADLE_ARGS[@]}" || { + echo "Gradle publish/release failed" >&2 + exit 1 + } +else + echo "RELEASE" + ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository "${GRADLE_ARGS[@]}" || { + echo "Gradle publish/release failed" >&2 + exit 1 + } +fi diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 547334574a..6d44502643 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -26,6 +26,10 @@ on: description: 'The tag name for the python package. Should be set when publishing a python package.' type: string required: false + release_launchdarkly_java: + description: 'Should release the launchdarkly-java-server-sdk-observability package.' + type: boolean + required: true release_launchdarkly_dotnet: description: 'Should release @launchdarkly/observability-dotnet package.' type: boolean @@ -143,6 +147,24 @@ jobs: dry-run: ${{ inputs.dry-run }} prerelease: ${{ inputs.prerelease }} + publish-java-sdk: + runs-on: ubuntu-latest + if: ${{ inputs.release_launchdarkly_java == true }} + permissions: + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Publish Java SDK + id: publish + uses: ./.github/actions/publish-java-sdk + with: + workspace-path: sdk/@launchdarkly/observability-java + aws-role-arn: ${{ vars.AWS_ROLE_ARN }} + dry-run: ${{ inputs.dry-run }} + prerelease: ${{ inputs.prerelease }} + publish-dotnet-sdk: runs-on: ubuntu-latest if: ${{ inputs.release_launchdarkly_dotnet == true }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7ab4689f9d..a4132ba23e 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -24,6 +24,8 @@ jobs: ruby-plugin-tag-name: ${{ steps.release.outputs['sdk/@launchdarkly/observability-ruby--tag_name'] }} flutter-plugin-released: ${{ steps.release.outputs['sdk/flutter/packages/observability--release_created'] }} flutter-plugin-tag-name: ${{ steps.release.outputs['sdk/flutter/packages/observability--tag_name'] }} + java-plugin-released: ${{ steps.release.outputs['sdk/@launchdarkly/observability-java--release_created'] }} + java-plugin-tag-name: ${{ steps.release.outputs['sdk/@launchdarkly/observability-java--tag_name'] }} steps: # Normally a workflow cannot trigger another workflow. For this workflow we need to run required checks. # Github determines that something is created by a workflow/bot based on the token. @@ -101,6 +103,24 @@ jobs: workspace-path: sdk/@launchdarkly/observability-android aws-role-arn: ${{ vars.AWS_ROLE_ARN }} + release-java-plugin: + runs-on: ubuntu-latest + permissions: + id-token: write # Used for publishing secrets. + contents: write + needs: ['release-package'] + if: ${{ needs.release-package.outputs.java-plugin-released == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Publish Java SDK + id: publish + uses: ./.github/actions/publish-java-sdk + with: + workspace-path: sdk/@launchdarkly/observability-java + aws-role-arn: ${{ vars.AWS_ROLE_ARN }} + release-dotnet-plugin: runs-on: ubuntu-latest permissions: