diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index 38dd9222..9ccd2236 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -3123,9 +3123,8 @@ limitations under the License. ## block-buffer - 0.10.4 **Repository URL**: https://github.com/RustCrypto/utils -**License Type(s)**: MIT OR Apache-2.0 -### License: https://spdx.org/licenses/ -### License File: LICENSE-APACHE +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html ``` Apache License Version 2.0, January 2004 @@ -3328,35 +3327,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -``` - -### License File: LICENSE-MIT -``` -Copyright (c) 2018-2019 The RustCrypto Project Developers - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. ``` ## block-buffer - 0.12.0 @@ -7507,6 +7478,215 @@ limitations under the License. ``` +## cpufeatures - 0.2.17 +**Repository URL**: https://github.com/RustCrypto/utils +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## cpufeatures - 0.3.0 **Repository URL**: https://github.com/RustCrypto/utils **License Type(s)**: Apache-2.0 @@ -7927,9 +8107,8 @@ limitations under the License. ## crypto-common - 0.1.7 **Repository URL**: https://github.com/RustCrypto/traits -**License Type(s)**: MIT OR Apache-2.0 -### License: https://spdx.org/licenses/ -### License File: LICENSE-APACHE +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html ``` Apache License Version 2.0, January 2004 @@ -8132,35 +8311,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -``` - -### License File: LICENSE-MIT -``` -Copyright (c) 2021 RustCrypto Developers - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. ``` ## crypto-common - 0.2.1 @@ -8581,6 +8732,36 @@ Apache License ``` +## data-encoding - 2.11.0 +**Repository URL**: https://github.com/ia0/data-encoding +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +The MIT License (MIT) + +Copyright (c) 2015-2020 Julien Cretin +Copyright (c) 2017-2020 Google Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + ## dialoguer - 0.11.0 **Repository URL**: https://github.com/console-rs/dialoguer **License Type(s)**: MIT @@ -8613,9 +8794,8 @@ SOFTWARE. ## digest - 0.10.7 **Repository URL**: https://github.com/RustCrypto/traits -**License Type(s)**: MIT OR Apache-2.0 -### License: https://spdx.org/licenses/ -### License File: LICENSE-APACHE +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html ``` Apache License Version 2.0, January 2004 @@ -8818,35 +8998,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -``` -### License File: LICENSE-MIT -``` -Copyright (c) 2017 Artyom Pavlov - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. ``` ## digest - 0.11.2 @@ -13360,28 +13512,27 @@ limitations under the License. **Repository URL**: https://github.com/fizyk20/generic-array.git **License Type(s)**: MIT ### License: https://spdx.org/licenses/MIT.html -### License File: LICENSE ``` -The MIT License (MIT) - -Copyright (c) 2015 Bartłomiej Kamiński - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` @@ -32034,6 +32185,215 @@ limitations under the License. ``` +## sha1 - 0.10.6 +**Repository URL**: https://github.com/RustCrypto/hashes +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## sha1_smol - 1.0.1 **Repository URL**: https://github.com/mitsuhiko/sha1-smol **License Type(s)**: BSD-3-Clause @@ -35352,6 +35712,34 @@ SOFTWARE. ``` +## tokio-tungstenite - 0.27.0 +**Repository URL**: https://github.com/snapview/tokio-tungstenite +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +Copyright (c) 2017 Daniel Abramov +Copyright (c) 2017 Alexey Galakhov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +``` + ## tokio-util - 0.7.18 **Repository URL**: https://github.com/tokio-rs/tokio **License Type(s)**: MIT @@ -36742,6 +37130,215 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +## tungstenite - 0.27.0 +**Repository URL**: https://github.com/snapview/tungstenite-rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + ``` ## typed-builder - 0.23.2 @@ -38123,281 +38720,362 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -``` - -### License File: LICENSE-MIT -``` -Copyright (c) 2015 The Rust Project Developers - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. +``` + +### License File: LICENSE-MIT +``` +Copyright (c) 2015 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +``` + +## unsafe-libyaml - 0.2.11 +**Repository URL**: https://github.com/dtolnay/unsafe-libyaml +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## untrusted - 0.9.0 +**Repository URL**: https://github.com/briansmith/untrusted +**License Type(s)**: ISC +### License: https://spdx.org/licenses/ISC.html +``` +// Copyright 2015-2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +``` + +## url - 2.5.8 +**Repository URL**: https://github.com/servo/rust-url +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. ``` -## unsafe-libyaml - 0.2.11 -**Repository URL**: https://github.com/dtolnay/unsafe-libyaml -**License Type(s)**: MIT -### License: https://spdx.org/licenses/MIT.html -``` -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. - -``` - -## untrusted - 0.9.0 -**Repository URL**: https://github.com/briansmith/untrusted -**License Type(s)**: ISC -### License: https://spdx.org/licenses/ISC.html -``` -// Copyright 2015-2016 Brian Smith. -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR -// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -``` - -## url - 2.5.8 -**Repository URL**: https://github.com/servo/rust-url +## utf-8 - 0.7.6 +**Repository URL**: https://github.com/SimonSapin/rust-utf8 **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] @@ -38405,7 +39083,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 +http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -38917,9 +39595,8 @@ limitations under the License. ## version_check - 0.9.5 **Repository URL**: https://github.com/SergioBenitez/version_check -**License Type(s)**: MIT/Apache-2.0 -### License: https://spdx.org/licenses/ -### License File: LICENSE-APACHE +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html ``` Apache License Version 2.0, January 2004 @@ -39122,29 +39799,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -``` -### License File: LICENSE-MIT -``` -The MIT License (MIT) -Copyright (c) 2017-2018 Sergio Benitez - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ## walkdir - 2.5.0 diff --git a/Cargo.lock b/Cargo.lock index 0246c835..14a9f718 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,7 +307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -459,6 +459,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -503,6 +512,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "dialoguer" version = "0.11.0" @@ -1319,6 +1334,7 @@ dependencies = [ "bitflags", "chrono", "futures", + "futures-util", "getrandom 0.3.4", "js-sys", "object_store", @@ -1335,6 +1351,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-tungstenite", "tonic", "typed-builder", "uuid", @@ -1386,6 +1403,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-tungstenite", "toml", "toml_edit", "tower", @@ -2409,6 +2427,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -2422,7 +2451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "digest 0.11.2", ] @@ -2542,7 +2571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2663,6 +2692,22 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2852,6 +2897,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.3", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typed-builder" version = "0.23.2" @@ -2926,6 +2990,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ca1d49d2..f500c03a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -20,6 +20,10 @@ pkg-fmt = "bin" [lints] workspace = true +[features] +default = ["atof-streaming"] +atof-streaming = ["nemo-relay/atof-streaming"] + [dependencies] nemo-relay = { workspace = true, features = ["guardrails-remote", "object-store", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } @@ -40,6 +44,7 @@ serde_json = "1" serde_yaml = "0.9" thiserror = "2" tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-tungstenite = { version = "0.27", default-features = false, features = ["connect", "rustls-tls-native-roots"] } toml = "0.9" toml_edit = "0.23" uuid = { workspace = true, features = ["serde", "v7"] } diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 2d93cc94..7b2898e2 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -13,12 +13,16 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; +use futures_util::SinkExt; +use nemo_relay::api::event::{BaseEvent, Event, MarkEvent}; use nemo_relay::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND; use nemo_relay::plugin::{DiagnosticLevel, PluginConfig, validate_plugin_config}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use serde::Serialize; -use serde_json::Value; +use serde_json::{Value, json}; use tokio::time::timeout; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use uuid::Uuid; use crate::config::{ AgentConfigs, CodingAgent, GatewayConfig, ResolvedConfig, ServerArgs, resolve_server_config, @@ -646,6 +650,17 @@ async fn collect_observability_component_checks(checks: &mut Vec, config: checks.push(check); } } + if section_enabled(config, "atof") && atof_endpoint_count(config) > 0 { + if atof_streaming_supported() { + checks.extend(observability_atof_endpoint_checks(config).await); + } else { + checks.push(Check { + name: "ATOF endpoint", + status: Status::Fail, + details: "ATOF streaming endpoints are not available in this binary".into(), + }); + } + } } fn observability_file_exporter_check(config: &Value, section: &str) -> Option { @@ -725,6 +740,302 @@ fn section_endpoint(config: &Value, section: &str) -> Option { .map(str::to_string) } +fn atof_endpoint_count(config: &Value) -> usize { + config + .get("atof") + .and_then(|section| section.get("endpoints")) + .and_then(Value::as_array) + .map_or(0, Vec::len) +} + +fn atof_streaming_supported() -> bool { + cfg!(all(feature = "atof-streaming", not(target_arch = "wasm32"))) +} + +async fn observability_atof_endpoint_checks(config: &Value) -> Vec { + let Some(endpoints) = config + .get("atof") + .and_then(|section| section.get("endpoints")) + .and_then(Value::as_array) + else { + return Vec::new(); + }; + let mut checks = Vec::with_capacity(endpoints.len()); + for (index, endpoint) in endpoints.iter().enumerate() { + checks.push(probe_atof_endpoint(index, endpoint).await); + } + checks +} + +async fn probe_atof_endpoint(index: usize, endpoint: &Value) -> Check { + let name = "ATOF endpoint"; + let Some(url) = endpoint.get("url").and_then(Value::as_str) else { + return Check { + name, + status: Status::Fail, + details: format!("endpoints[{index}]: missing url"), + }; + }; + let transport = endpoint + .get("transport") + .and_then(Value::as_str) + .unwrap_or("http_post"); + let timeout_millis = endpoint + .get("timeout_millis") + .and_then(Value::as_u64) + .unwrap_or(3_000); + if timeout_millis == 0 { + return Check { + name, + status: Status::Fail, + details: format!("endpoints[{index}] {transport} {url}: timeout_millis must be > 0"), + }; + } + let headers = match endpoint_headers(endpoint) { + Ok(headers) => headers, + Err(err) => { + return Check { + name, + status: Status::Fail, + details: format!("endpoints[{index}] {transport} {url}: {err}"), + }; + } + }; + let payload = match doctor_atof_probe_payload() { + Ok(payload) => payload, + Err(err) => { + return Check { + name, + status: Status::Fail, + details: format!("endpoints[{index}] {transport} {url}: {err}"), + }; + } + }; + let timeout_duration = Duration::from_millis(timeout_millis); + match transport { + "http_post" => probe_atof_http_post(url, headers, payload, timeout_duration, index).await, + "websocket" => probe_atof_websocket(url, headers, payload, timeout_duration, index).await, + "ndjson" => probe_atof_ndjson(url, headers, payload, timeout_duration, index).await, + _ => Check { + name, + status: Status::Fail, + details: format!("endpoints[{index}] {transport} {url}: unsupported transport"), + }, + } +} + +fn endpoint_headers(endpoint: &Value) -> Result, String> { + let Some(headers) = endpoint.get("headers") else { + return Ok(Vec::new()); + }; + let Some(object) = headers.as_object() else { + return Err("headers must be an object of string values".into()); + }; + let mut out = Vec::with_capacity(object.len()); + for (key, value) in object { + let Some(value) = value.as_str() else { + return Err(format!("headers.{key} must be a string")); + }; + out.push((key.clone(), value.to_string())); + } + Ok(out) +} + +fn doctor_atof_probe_payload() -> Result { + let event = Event::Mark(MarkEvent::new( + BaseEvent::builder() + .uuid(Uuid::now_v7()) + .name("nemo_relay.doctor.atof_probe") + .data(json!({"doctor": true})) + .metadata(json!({"source": "nemo-relay doctor"})) + .build(), + None, + None, + )); + event + .try_to_json_value() + .and_then(|value| serde_json::to_string(&value)) + .map_err(|error| error.to_string()) +} + +async fn probe_atof_http_post( + url: &str, + headers: Vec<(String, String)>, + payload: String, + timeout_duration: Duration, + index: usize, +) -> Check { + probe_atof_http_upload(url, headers, payload, timeout_duration, index, "http_post").await +} + +async fn probe_atof_ndjson( + url: &str, + headers: Vec<(String, String)>, + payload: String, + timeout_duration: Duration, + index: usize, +) -> Check { + probe_atof_http_upload(url, headers, payload, timeout_duration, index, "ndjson").await +} + +async fn probe_atof_http_upload( + url: &str, + headers: Vec<(String, String)>, + payload: String, + timeout_duration: Duration, + index: usize, + transport: &str, +) -> Check { + crate::tls::install_rustls_crypto_provider(); + let client = match reqwest::Client::builder().timeout(timeout_duration).build() { + Ok(client) => client, + Err(err) => { + return Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!( + "endpoints[{index}] {transport} {url}: could not build client: {err}" + ), + }; + } + }; + let mut request = client + .post(url) + .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson") + .body(format!("{payload}\n")); + for (key, value) in headers { + request = request.header(key, value); + } + match request.send().await { + Ok(response) if response.status().is_success() => Check { + name: "ATOF endpoint", + status: Status::Pass, + details: format!( + "endpoints[{index}] {transport} {url} (HTTP {})", + response.status() + ), + }, + Ok(response) => Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!( + "endpoints[{index}] {transport} {url} (HTTP {})", + response.status() + ), + }, + Err(err) => Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!("endpoints[{index}] {transport} {url}: {err}"), + }, + } +} + +async fn probe_atof_websocket( + url: &str, + headers: Vec<(String, String)>, + payload: String, + timeout_duration: Duration, + index: usize, +) -> Check { + match reqwest::Url::parse(url) { + Ok(parsed) if matches!(parsed.scheme(), "ws" | "wss") => {} + Ok(_) => { + return Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!( + "endpoints[{index}] websocket {url}: invalid scheme (must be ws or wss)" + ), + }; + } + Err(err) => { + return Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!("endpoints[{index}] websocket {url}: {err}"), + }; + } + } + let mut request = match url.into_client_request() { + Ok(request) => request, + Err(err) => { + return Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!("endpoints[{index}] websocket {url}: {err}"), + }; + } + }; + for (key, value) in headers { + let name = match tokio_tungstenite::tungstenite::http::header::HeaderName::from_bytes( + key.as_bytes(), + ) { + Ok(name) => name, + Err(err) => { + return Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!("endpoints[{index}] websocket {url}: {err}"), + }; + } + }; + let value = + match tokio_tungstenite::tungstenite::http::header::HeaderValue::from_str(&value) { + Ok(value) => value, + Err(err) => { + return Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!("endpoints[{index}] websocket {url}: {err}"), + }; + } + }; + request.headers_mut().insert(name, value); + } + match timeout(timeout_duration, tokio_tungstenite::connect_async(request)).await { + Ok(Ok((mut socket, _))) => { + let send = timeout( + timeout_duration, + socket.send(tokio_tungstenite::tungstenite::Message::Text( + payload.into(), + )), + ) + .await; + let _ = timeout(timeout_duration, socket.close(None)).await; + match send { + Ok(Ok(())) => Check { + name: "ATOF endpoint", + status: Status::Pass, + details: format!("endpoints[{index}] websocket {url}"), + }, + Ok(Err(err)) => Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!("endpoints[{index}] websocket {url}: {err}"), + }, + Err(_) => Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!( + "endpoints[{index}] websocket {url}: timed out sending probe payload" + ), + }, + } + } + Ok(Err(err)) => Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!("endpoints[{index}] websocket {url}: {err}"), + }, + Err(_) => Check { + name: "ATOF endpoint", + status: Status::Fail, + details: format!("endpoints[{index}] websocket {url}: timed out"), + }, + } +} + fn check_directory(name: &'static str, path: &Path) -> Check { match check_dir_writable(path) { Ok(()) => Check { diff --git a/crates/cli/src/plugins/editor_model.rs b/crates/cli/src/plugins/editor_model.rs index 4c8fab0e..3bcbf1cd 100644 --- a/crates/cli/src/plugins/editor_model.rs +++ b/crates/cli/src/plugins/editor_model.rs @@ -33,9 +33,9 @@ pub(super) struct ComponentEditorState { #[derive(Debug)] pub(super) enum EditableComponent { - Observability(ComponentEditorState), - Adaptive(ComponentEditorState), - NemoGuardrails(ComponentEditorState), + Observability(Box>), + Adaptive(Box>), + NemoGuardrails(Box>), } impl EditableComponent { @@ -148,9 +148,9 @@ pub(super) fn editable_components( config: &PluginConfig, ) -> Result, CliError> { Ok(vec![ - EditableComponent::Observability(component_observability_state(config)?), - EditableComponent::Adaptive(component_adaptive_state(config)?), - EditableComponent::NemoGuardrails(component_nemo_guardrails_state(config)?), + EditableComponent::Observability(Box::new(component_observability_state(config)?)), + EditableComponent::Adaptive(Box::new(component_adaptive_state(config)?)), + EditableComponent::NemoGuardrails(Box::new(component_nemo_guardrails_state(config)?)), ]) } diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 6cfcabdd..f2ef6891 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -3,11 +3,47 @@ use super::*; use std::ffi::OsString; +use std::io::{Read, Write}; +use std::net::TcpListener; use std::path::PathBuf; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; static ENV_LOCK: Mutex<()> = Mutex::new(()); +fn start_doctor_http_capture_server() -> (String, Arc>, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let body = Arc::new(Mutex::new(String::new())); + let thread_body = Arc::clone(&body); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut data = Vec::new(); + let mut buf = [0_u8; 1]; + while !data.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut buf).unwrap(); + data.push(buf[0]); + } + let headers = String::from_utf8_lossy(&data).to_string(); + let length = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then_some(value.trim()) + }) + }) + .and_then(|value| value.trim().parse::().ok()) + .unwrap(); + let mut request_body = vec![0_u8; length]; + stream.read_exact(&mut request_body).unwrap(); + *thread_body.lock().unwrap() = String::from_utf8(request_body).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .unwrap(); + }); + (url, body, handle) +} + struct EnvScope { values: Vec<(&'static str, Option)>, } @@ -657,6 +693,90 @@ async fn collect_observability_registers_adaptive_before_validation() { ); } +#[tokio::test] +async fn collect_observability_probes_atof_streaming_endpoint() { + let (url, body, server_thread) = start_doctor_http_capture_server(); + let gateway = GatewayConfig { + plugin_config: Some(serde_json::json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": true, + "endpoints": [{ + "url": url, + "transport": "http_post", + "headers": {"X-Test": "doctor"} + }] + } + } + }] + })), + ..GatewayConfig::default() + }; + + let checks = collect_observability(&gateway).await; + let body = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let captured = body.lock().unwrap().clone(); + if captured.contains("\"kind\":\"mark\"") + && captured.contains("\"name\":\"nemo_relay.doctor.atof_probe\"") + { + break captured; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("doctor probe body should be captured"); + + let endpoint = checks + .iter() + .find(|check| check.name == "ATOF endpoint") + .expect("ATOF endpoint check"); + assert_eq!(endpoint.status, Status::Pass); + assert!(body.contains("\"kind\":\"mark\"")); + assert!(body.contains("\"name\":\"nemo_relay.doctor.atof_probe\"")); + server_thread.join().unwrap(); +} + +#[tokio::test] +async fn collect_observability_rejects_websocket_endpoint_http_scheme() { + let gateway = GatewayConfig { + plugin_config: Some(serde_json::json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": true, + "endpoints": [{ + "url": "http://localhost:9/events", + "transport": "websocket" + }] + } + } + }] + })), + ..GatewayConfig::default() + }; + + let checks = collect_observability(&gateway).await; + + let endpoint = checks + .iter() + .find(|check| check.name == "ATOF endpoint") + .expect("ATOF endpoint check"); + assert_eq!(endpoint.status, Status::Fail); + assert!(endpoint.details.contains("invalid scheme")); + assert!(endpoint.details.contains("must be ws or wss")); +} + #[test] fn format_agents_human_lists_supported_and_separates_detected() { let agents = vec![ diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index fd557468..72139869 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -14,7 +14,22 @@ readme = "README.md" workspace = true [features] -default = ["otel", "openinference", "guardrails-remote", "object-store"] +default = [ + "otel", + "openinference", + "guardrails-remote", + "object-store", +] +atof-streaming = [ + "dep:futures-util", + "dep:reqwest", + "dep:rustls", + "dep:tokio-tungstenite", + "tokio/io-util", + "tokio/net", + "tokio/rt-multi-thread", + "tokio/time", +] schema = ["dep:schemars"] guardrails-remote = [ "dep:reqwest", @@ -68,8 +83,9 @@ chrono = { version = "0.4", features = ["serde"] } bitflags = { version = "2", features = ["serde"] } thiserror = "2" tokio = { version = "1", default-features = false, features = ["rt", "macros", "sync"] } -tokio-stream = { version = "0.1", default-features = false } +tokio-stream = { version = "0.1", default-features = false, features = ["sync"] } typed-builder = "0.23.2" +futures-util = { version = "0.3", optional = true } opentelemetry = { version = "0.31", default-features = false, features = ["trace"], optional = true } opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace"], optional = true } openinference-semantic-conventions = { version = "0.1.1", optional = true } @@ -130,7 +146,8 @@ opentelemetry-otlp = { version = "0.31.1", default-features = false, features = [target.'cfg(not(target_arch = "wasm32"))'.dependencies] opentelemetry-otlp = { version = "0.31.1", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "grpc-tonic"], optional = true } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots-no-provider"], optional = true } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots-no-provider", "stream"], optional = true } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true } tonic = { version = "0.14.1", default-features = false, optional = true } object_store = { version = "0.13", default-features = false, features = ["aws"], optional = true } +tokio-tungstenite = { version = "0.27", default-features = false, features = ["connect", "rustls-tls-native-roots"], optional = true } diff --git a/crates/core/src/observability/atof.rs b/crates/core/src/observability/atof.rs index 9c41b766..b307cc28 100644 --- a/crates/core/src/observability/atof.rs +++ b/crates/core/src/observability/atof.rs @@ -8,12 +8,20 @@ //! canonical NeMo Relay Agent Trajectory Observability Format (ATOF) event as //! one JSON object per JSONL line. +use std::collections::HashMap; use std::fs::{File, OpenOptions}; use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, mpsc as std_mpsc}; +use std::time::Duration; use chrono::Utc; +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +use futures_util::{SinkExt, stream}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +use tokio_tungstenite::tungstenite::client::IntoClientRequest; use crate::api::event::Event; use crate::api::runtime::EventSubscriberFn; @@ -53,6 +61,9 @@ pub enum AtofExporterError { /// Stored failure message. message: String, }, + /// A streaming endpoint configuration is invalid. + #[error("invalid ATOF streaming endpoint: {0}")] + InvalidEndpoint(String), /// The internal exporter state lock was poisoned. #[error("the ATOF exporter state lock was poisoned")] LockPoisoned, @@ -62,7 +73,8 @@ pub enum AtofExporterError { } /// File write behavior for [`AtofExporter`]. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum AtofExporterMode { /// Append events to an existing file or create it if missing. #[default] @@ -90,15 +102,95 @@ impl AtofExporterMode { } } +/// Streaming transport used by an ATOF endpoint. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AtofEndpointTransport { + /// POST each event as one JSONL record. + #[default] + HttpPost, + /// Send each event as one WebSocket JSON text message. + Websocket, + /// Stream events over one long-lived HTTP NDJSON upload. + Ndjson, +} + +impl AtofEndpointTransport { + /// Parse a string transport used by configuration and bindings. + pub fn parse(value: &str) -> Option { + match value { + "http_post" => Some(Self::HttpPost), + "websocket" => Some(Self::Websocket), + "ndjson" => Some(Self::Ndjson), + _ => None, + } + } + + /// Return the stable string representation used by configuration and bindings. + pub fn as_str(self) -> &'static str { + match self { + Self::HttpPost => "http_post", + Self::Websocket => "websocket", + Self::Ndjson => "ndjson", + } + } +} + +/// Streaming destination for raw ATOF events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AtofEndpointConfig { + /// Endpoint URL. + pub url: String, + /// Endpoint transport. + #[serde(default)] + pub transport: AtofEndpointTransport, + /// Headers applied to endpoint requests or handshakes. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: HashMap, + /// Per-endpoint timeout in milliseconds. + #[serde(default = "default_endpoint_timeout_millis")] + pub timeout_millis: u64, +} + +impl AtofEndpointConfig { + /// Create a streaming endpoint with defaults. + pub fn new(url: impl Into, transport: AtofEndpointTransport) -> Self { + Self { + url: url.into(), + transport, + headers: HashMap::new(), + timeout_millis: default_endpoint_timeout_millis(), + } + } + + /// Add a header to this endpoint config. + pub fn with_header(mut self, key: impl Into, value: impl Into) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + /// Override the endpoint timeout. + pub fn with_timeout_millis(mut self, timeout_millis: u64) -> Self { + self.timeout_millis = timeout_millis; + self + } +} + /// Configuration for [`AtofExporter`]. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AtofExporterConfig { /// Directory that contains the JSONL output file. + #[serde(default = "default_output_directory")] pub output_directory: PathBuf, /// Append or overwrite behavior used when opening the file. + #[serde(default)] pub mode: AtofExporterMode, /// Output filename. + #[serde(default = "default_filename")] pub filename: String, + /// Optional streaming endpoints that receive every raw ATOF event. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, } impl Default for AtofExporterConfig { @@ -107,6 +199,7 @@ impl Default for AtofExporterConfig { output_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), mode: AtofExporterMode::Append, filename: default_filename(), + endpoints: Vec::new(), } } } @@ -135,6 +228,18 @@ impl AtofExporterConfig { self } + /// Override streaming endpoints. + pub fn with_endpoints(mut self, endpoints: Vec) -> Self { + self.endpoints = endpoints; + self + } + + /// Add one streaming endpoint. + pub fn with_endpoint(mut self, endpoint: AtofEndpointConfig) -> Self { + self.endpoints.push(endpoint); + self + } + /// Return the full output path for this config. pub fn path(&self) -> PathBuf { self.output_directory.join(&self.filename) @@ -144,6 +249,8 @@ impl AtofExporterConfig { struct AtofExporterState { writer: BufWriter, last_error: Option, + endpoints: Vec, + closed: bool, } /// Filesystem-backed Agent Trajectory Observability Format (ATOF) JSONL event exporter. @@ -157,11 +264,14 @@ impl AtofExporter { pub fn new(config: AtofExporterConfig) -> Result { let path = config.path(); let file = open_file(&path, config.mode)?; + let endpoints = start_endpoint_workers(&config.endpoints)?; Ok(Self { path, state: Arc::new(Mutex::new(AtofExporterState { writer: BufWriter::new(file), last_error: None, + endpoints, + closed: false, })), }) } @@ -178,11 +288,23 @@ impl AtofExporter { let Ok(mut state) = state.lock() else { return; }; - if state.last_error.is_some() { + if state.closed || state.last_error.is_some() { return; } - if let Err(error) = write_event(&mut state.writer, event) { + let Ok(value) = event.try_to_json_value() else { + state.last_error = Some("failed to serialize ATOF event".to_string()); + return; + }; + if let Err(error) = write_json_value(&mut state.writer, &value) { state.last_error = Some(error); + return; + } + let Ok(raw_json) = serde_json::to_string(&value) else { + state.last_error = Some("failed to serialize ATOF event".to_string()); + return; + }; + for endpoint in &state.endpoints { + endpoint.enqueue(raw_json.clone()); } }) } @@ -197,13 +319,16 @@ impl AtofExporter { deregister_subscriber(name).map_err(Into::into) } - /// Flush the underlying file and report any stored write error. + /// Flush the underlying file and drain queued endpoint events. pub fn force_flush(&self) -> Result<()> { flush_subscribers()?; let mut state = self .state .lock() .map_err(|_| AtofExporterError::LockPoisoned)?; + if state.closed { + return stored_failure_result(&self.path, &state); + } state .writer .flush() @@ -211,18 +336,35 @@ impl AtofExporter { path: self.path.clone(), source, })?; - if let Some(message) = &state.last_error { - return Err(AtofExporterError::StoredFailure { - path: self.path.clone(), - message: message.clone(), - }); + for endpoint in &state.endpoints { + endpoint.flush(); } - Ok(()) + stored_failure_result(&self.path, &state) } - /// Shut down the exporter by flushing any buffered data. + /// Shut down the exporter by flushing buffered data and closing endpoints. pub fn shutdown(&self) -> Result<()> { - self.force_flush() + flush_subscribers()?; + let mut state = self + .state + .lock() + .map_err(|_| AtofExporterError::LockPoisoned)?; + if state.closed { + return stored_failure_result(&self.path, &state); + } + state.closed = true; + let flush_result = state + .writer + .flush() + .map_err(|source| AtofExporterError::Flush { + path: self.path.clone(), + source, + }); + for endpoint in &state.endpoints { + endpoint.close(); + } + flush_result?; + stored_failure_result(&self.path, &state) } } @@ -233,6 +375,14 @@ fn default_filename() -> String { ) } +fn default_output_directory() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +fn default_endpoint_timeout_millis() -> u64 { + 3_000 +} + fn open_file(path: &Path, mode: AtofExporterMode) -> Result { let mut options = OpenOptions::new(); options.create(true); @@ -252,15 +402,484 @@ fn open_file(path: &Path, mode: AtofExporterMode) -> Result { }) } -fn write_event(writer: &mut BufWriter, event: &Event) -> std::result::Result<(), String> { - let value = event - .try_to_json_value() - .map_err(|error| error.to_string())?; - serde_json::to_writer(&mut *writer, &value).map_err(|error| error.to_string())?; +fn write_json_value(writer: &mut BufWriter, value: &Json) -> std::result::Result<(), String> { + serde_json::to_writer(&mut *writer, value).map_err(|error| error.to_string())?; writer.write_all(b"\n").map_err(|error| error.to_string())?; writer.flush().map_err(|error| error.to_string()) } +fn stored_failure_result(path: &Path, state: &AtofExporterState) -> Result<()> { + if let Some(message) = &state.last_error { + return Err(AtofExporterError::StoredFailure { + path: path.to_path_buf(), + message: message.clone(), + }); + } + Ok(()) +} + +#[cfg_attr( + any(not(feature = "atof-streaming"), target_arch = "wasm32"), + allow(dead_code) +)] +enum EndpointMessage { + Event(String), + Flush(std_mpsc::Sender<()>), + Close(std_mpsc::Sender<()>), +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +enum NdjsonBodyMessage { + Event(Vec), + Flush(std_mpsc::Sender<()>), +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +impl NdjsonBodyMessage { + fn acknowledge_if_flush(self) { + if let Self::Flush(done) = self { + let _ = done.send(()); + } + } +} + +struct AtofEndpointWorker { + sender: tokio::sync::mpsc::UnboundedSender, + timeout: Duration, +} + +impl AtofEndpointWorker { + fn enqueue(&self, raw_json: String) { + let _ = self.sender.send(EndpointMessage::Event(raw_json)); + } + + fn flush(&self) { + let (tx, rx) = std_mpsc::channel(); + if self.sender.send(EndpointMessage::Flush(tx)).is_ok() + && rx.recv_timeout(self.timeout).is_err() + { + eprintln!("nemo_relay: timed out flushing ATOF endpoint"); + } + } + + fn close(&self) { + let (tx, rx) = std_mpsc::channel(); + if self.sender.send(EndpointMessage::Close(tx)).is_ok() + && rx.recv_timeout(self.timeout).is_err() + { + eprintln!("nemo_relay: timed out closing ATOF endpoint"); + } + } +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn start_endpoint_workers(configs: &[AtofEndpointConfig]) -> Result> { + let mut workers = Vec::with_capacity(configs.len()); + for (index, config) in configs.iter().enumerate() { + match start_endpoint_worker(index, config.clone()) { + Ok(worker) => workers.push(worker), + Err(error) => { + eprintln!("nemo_relay: invalid ATOF endpoint[{index}]: {error}"); + return Err(AtofExporterError::InvalidEndpoint(format!( + "endpoints[{index}]: {error}" + ))); + } + } + } + Ok(workers) +} + +#[cfg(any(not(feature = "atof-streaming"), target_arch = "wasm32"))] +fn start_endpoint_workers(configs: &[AtofEndpointConfig]) -> Result> { + if configs.is_empty() { + return Ok(Vec::new()); + } + let message = "ATOF streaming endpoints are not supported in this build".to_string(); + eprintln!("nemo_relay: {message}"); + Err(AtofExporterError::InvalidEndpoint(message)) +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn start_endpoint_worker(index: usize, config: AtofEndpointConfig) -> Result { + validate_endpoint_config(&config)?; + let timeout = Duration::from_millis(config.timeout_millis); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + std::thread::Builder::new() + .name(format!("nemo-relay-atof-endpoint-{index}")) + .spawn(move || run_endpoint_worker(index, config, rx)) + .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?; + Ok(AtofEndpointWorker { + sender: tx, + timeout, + }) +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn validate_endpoint_config(config: &AtofEndpointConfig) -> Result<()> { + if config.url.trim().is_empty() { + return Err(AtofExporterError::InvalidEndpoint( + "endpoint url must be non-empty".to_string(), + )); + } + if config.timeout_millis == 0 { + return Err(AtofExporterError::InvalidEndpoint( + "endpoint timeout_millis must be greater than 0".to_string(), + )); + } + let url = reqwest::Url::parse(&config.url) + .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?; + let valid_scheme = match config.transport { + AtofEndpointTransport::HttpPost | AtofEndpointTransport::Ndjson => { + matches!(url.scheme(), "http" | "https") + } + AtofEndpointTransport::Websocket => matches!(url.scheme(), "ws" | "wss"), + }; + if !valid_scheme { + return Err(AtofExporterError::InvalidEndpoint(format!( + "endpoint {} transport does not support URL scheme {:?}", + config.transport.as_str(), + url.scheme() + ))); + } + build_header_map(&config.headers)?; + Ok(()) +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn build_header_map(headers: &HashMap) -> Result { + let mut out = reqwest::header::HeaderMap::new(); + for (key, value) in headers { + let name = reqwest::header::HeaderName::from_bytes(key.as_bytes()) + .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?; + let value = reqwest::header::HeaderValue::from_str(value) + .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?; + out.insert(name, value); + } + Ok(out) +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn run_endpoint_worker( + index: usize, + config: AtofEndpointConfig, + rx: tokio::sync::mpsc::UnboundedReceiver, +) { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] runtime failed: {error}"); + return; + } + }; + runtime.block_on(async move { + match config.transport { + AtofEndpointTransport::HttpPost => run_http_post_endpoint(index, config, rx).await, + AtofEndpointTransport::Websocket => run_websocket_endpoint(index, config, rx).await, + AtofEndpointTransport::Ndjson => run_ndjson_endpoint(index, config, rx).await, + } + }); +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +async fn run_http_post_endpoint( + index: usize, + config: AtofEndpointConfig, + mut rx: tokio::sync::mpsc::UnboundedReceiver, +) { + let client = match reqwest::Client::builder() + .timeout(Duration::from_millis(config.timeout_millis)) + .default_headers(match build_header_map(&config.headers) { + Ok(headers) => headers, + Err(error) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] disabled: {error}"); + drain_closed(rx).await; + return; + } + }) + .build() + { + Ok(client) => client, + Err(error) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] client build failed: {error}"); + drain_closed(rx).await; + return; + } + }; + while let Some(message) = rx.recv().await { + match message { + EndpointMessage::Event(raw_json) => { + let body = format!("{raw_json}\n"); + let result = client + .post(&config.url) + .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson") + .body(body) + .send() + .await; + match result { + Ok(response) if response.status().is_success() => {} + Ok(response) => eprintln!( + "nemo_relay: ATOF endpoint[{index}] HTTP status {}", + response.status() + ), + Err(error) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] send failed: {error}") + } + } + } + EndpointMessage::Flush(done) => { + let _ = done.send(()); + } + EndpointMessage::Close(done) => { + let _ = done.send(()); + return; + } + } + } +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +async fn run_websocket_endpoint( + index: usize, + config: AtofEndpointConfig, + mut rx: tokio::sync::mpsc::UnboundedReceiver, +) { + let mut pending = std::collections::VecDeque::new(); + let mut socket = match connect_websocket(&config).await { + Ok(socket) => Some(socket), + Err(error) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] websocket startup failed: {error}"); + None + } + }; + while let Some(message) = rx.recv().await { + match message { + EndpointMessage::Event(raw_json) => { + pending.push_back(raw_json); + let _ = drain_websocket_pending(index, &config, &mut socket, &mut pending).await; + } + EndpointMessage::Flush(done) => { + let _ = drain_websocket_pending(index, &config, &mut socket, &mut pending).await; + let _ = done.send(()); + } + EndpointMessage::Close(done) => { + let _ = drain_websocket_pending(index, &config, &mut socket, &mut pending).await; + if let Some(mut ws) = socket.take() { + let _ = ws.close(None).await; + } + let _ = done.send(()); + return; + } + } + } +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +type AtofWebSocket = + tokio_tungstenite::WebSocketStream>; + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +async fn drain_websocket_pending( + index: usize, + config: &AtofEndpointConfig, + socket: &mut Option, + pending: &mut std::collections::VecDeque, +) -> bool { + let timeout = Duration::from_millis(config.timeout_millis); + match tokio::time::timeout( + timeout, + drain_websocket_pending_inner(index, config, socket, pending), + ) + .await + { + Ok(drained) => drained, + Err(_) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] websocket drain timed out"); + false + } + } +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +async fn drain_websocket_pending_inner( + index: usize, + config: &AtofEndpointConfig, + socket: &mut Option, + pending: &mut std::collections::VecDeque, +) -> bool { + while let Some(raw_json) = pending.front().cloned() { + if socket.is_none() { + match connect_websocket(config).await { + Ok(ws) => *socket = Some(ws), + Err(error) => { + eprintln!( + "nemo_relay: ATOF endpoint[{index}] websocket reconnect failed: {error}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + } + } + + let Some(ws) = socket.as_mut() else { + continue; + }; + match ws + .send(tokio_tungstenite::tungstenite::Message::Text( + raw_json.into(), + )) + .await + { + Ok(()) => { + pending.pop_front(); + } + Err(error) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] websocket send failed: {error}"); + *socket = None; + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + } + true +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +async fn connect_websocket( + config: &AtofEndpointConfig, +) -> std::result::Result { + let mut request = config + .url + .as_str() + .into_client_request() + .map_err(|error| error.to_string())?; + for (key, value) in &config.headers { + let name = + tokio_tungstenite::tungstenite::http::header::HeaderName::from_bytes(key.as_bytes()) + .map_err(|error| error.to_string())?; + let value = tokio_tungstenite::tungstenite::http::header::HeaderValue::from_str(value) + .map_err(|error| error.to_string())?; + request.headers_mut().insert(name, value); + } + tokio::time::timeout( + Duration::from_millis(config.timeout_millis), + tokio_tungstenite::connect_async(request), + ) + .await + .map_err(|error| error.to_string())? + .map(|(socket, _)| socket) + .map_err(|error| error.to_string()) +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +async fn run_ndjson_endpoint( + index: usize, + config: AtofEndpointConfig, + mut rx: tokio::sync::mpsc::UnboundedReceiver, +) { + let client = match reqwest::Client::builder() + .connect_timeout(Duration::from_millis(config.timeout_millis)) + .default_headers(match build_header_map(&config.headers) { + Ok(headers) => headers, + Err(error) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] disabled: {error}"); + drain_closed(rx).await; + return; + } + }) + .build() + { + Ok(client) => client, + Err(error) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] client build failed: {error}"); + drain_closed(rx).await; + return; + } + }; + + let (body_tx, body_rx) = tokio::sync::mpsc::unbounded_channel::(); + let body_stream = stream::unfold(body_rx, |mut body_rx| async { + loop { + match body_rx.recv().await? { + NdjsonBodyMessage::Event(bytes) => { + return Some((Ok::<_, std::io::Error>(bytes), body_rx)); + } + NdjsonBodyMessage::Flush(done) => { + let _ = done.send(()); + } + } + } + }); + let body = reqwest::Body::wrap_stream(body_stream); + let request = tokio::spawn(async move { + client + .post(config.url) + .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson") + .body(body) + .send() + .await + }); + let close_timeout = Duration::from_millis(config.timeout_millis); + + while let Some(message) = rx.recv().await { + match message { + EndpointMessage::Event(raw_json) => { + if let Err(error) = body_tx.send(NdjsonBodyMessage::Event( + format!("{raw_json}\n").into_bytes(), + )) { + eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON send failed: {error}"); + } + } + EndpointMessage::Flush(done) => { + if let Err(error) = body_tx.send(NdjsonBodyMessage::Flush(done)) { + eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON flush failed: {error}"); + error.0.acknowledge_if_flush(); + } + } + EndpointMessage::Close(done) => { + drop(body_tx); + match tokio::time::timeout(close_timeout, request).await { + Ok(Ok(Ok(response))) if response.status().is_success() => {} + Ok(Ok(Ok(response))) => eprintln!( + "nemo_relay: ATOF endpoint[{index}] NDJSON HTTP status {}", + response.status() + ), + Ok(Ok(Err(error))) => { + eprintln!( + "nemo_relay: ATOF endpoint[{index}] NDJSON upload failed: {error}" + ) + } + Ok(Err(error)) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON task failed: {error}") + } + Err(_) => { + eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON close timed out") + } + } + let _ = done.send(()); + return; + } + } + } +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +async fn drain_closed(mut rx: tokio::sync::mpsc::UnboundedReceiver) { + while let Some(message) = rx.recv().await { + match message { + EndpointMessage::Flush(done) => { + let _ = done.send(()); + } + EndpointMessage::Close(done) => { + let _ = done.send(()); + return; + } + EndpointMessage::Event(_) => {} + } + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/crates/core/src/observability/plugin_component.rs b/crates/core/src/observability/plugin_component.rs index 1cf48eeb..bc42cf77 100644 --- a/crates/core/src/observability/plugin_component.rs +++ b/crates/core/src/observability/plugin_component.rs @@ -34,7 +34,8 @@ use crate::api::scope::ScopeType; use crate::api::subscriber::{scope_deregister_subscriber, scope_register_subscriber}; use crate::observability::atif::{AtifAgentInfo, AtifExporter}; use crate::observability::atof::{ - AtofExporter, AtofExporterConfig as CoreAtofExporterConfig, AtofExporterMode, + AtofEndpointConfig as CoreAtofEndpointConfig, AtofEndpointTransport, AtofExporter, + AtofExporterConfig as CoreAtofExporterConfig, AtofExporterMode, }; #[cfg(feature = "openinference")] use crate::observability::openinference::{ @@ -159,6 +160,9 @@ pub struct AtofSectionConfig { #[serde(default = "default_atof_mode")] #[cfg_attr(feature = "schema", schemars(schema_with = "atof_mode_schema"))] pub mode: String, + /// Optional streaming endpoints that receive every raw ATOF event. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, } impl Default for AtofSectionConfig { @@ -168,10 +172,32 @@ impl Default for AtofSectionConfig { output_directory: None, filename: None, mode: default_atof_mode(), + endpoints: Vec::new(), } } } +/// Streaming destination for raw ATOF events. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct AtofEndpointSectionConfig { + /// Endpoint URL. + pub url: String, + /// Transport: `http_post`, `websocket`, or `ndjson`. + #[serde(default = "default_atof_endpoint_transport")] + #[cfg_attr( + feature = "schema", + schemars(schema_with = "atof_endpoint_transport_schema") + )] + pub transport: String, + /// Headers applied to endpoint requests or handshakes. + #[serde(default)] + pub headers: HashMap, + /// Per-endpoint timeout in milliseconds. + #[serde(default = "default_timeout_millis")] + pub timeout_millis: u64, +} + /// Per-trajectory ATIF exporter config. /// /// When enabled, this section creates a dispatcher that opens a separate @@ -432,6 +458,7 @@ crate::editor_config! { output_directory => { label: "output_directory", kind: String, optional: true }, filename => { label: "filename", kind: String, optional: true }, mode => { label: "mode", kind: Enum, values: ["append", "overwrite"] }, + endpoints => { label: "endpoints", kind: Json, optional: true }, } } @@ -528,6 +555,17 @@ fn atof_mode_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemar string_enum_schema(generator, &["append", "overwrite"], Some("append")) } +#[cfg(feature = "schema")] +fn atof_endpoint_transport_schema( + generator: &mut schemars::r#gen::SchemaGenerator, +) -> schemars::schema::Schema { + string_enum_schema( + generator, + &["http_post", "websocket", "ndjson"], + Some("http_post"), + ) +} + #[cfg(feature = "schema")] fn otlp_transport_schema( generator: &mut schemars::r#gen::SchemaGenerator, @@ -588,6 +626,13 @@ fn register_atof_exporter( if let Some(filename) = section.filename { config = config.with_filename(filename); } + let endpoints = section + .endpoints + .into_iter() + .enumerate() + .map(|(index, endpoint)| build_atof_endpoint_config(index, endpoint)) + .collect::>>()?; + config = config.with_endpoints(endpoints); let exporter = Arc::new(AtofExporter::new(config).map_err(observability_registration_error)?); ctx.register_subscriber("atof", exporter.subscriber())?; @@ -603,6 +648,23 @@ fn register_atof_exporter( Ok(()) } +fn build_atof_endpoint_config( + index: usize, + endpoint: AtofEndpointSectionConfig, +) -> PluginResult { + let transport = AtofEndpointTransport::parse(&endpoint.transport).ok_or_else(|| { + PluginError::InvalidConfig(format!( + "ATOF endpoints[{index}].transport must be 'http_post', 'websocket', or 'ndjson'" + )) + })?; + let mut config = CoreAtofEndpointConfig::new(endpoint.url, transport) + .with_timeout_millis(endpoint.timeout_millis); + for (key, value) in endpoint.headers { + config = config.with_header(key, value); + } + Ok(config) +} + type AtifStorageList = Arc>>; fn register_atif_dispatcher( @@ -1308,7 +1370,13 @@ fn validate_observability_plugin_config( &config.policy, plugin_config, "atof", - &["enabled", "output_directory", "filename", "mode"], + &[ + "enabled", + "output_directory", + "filename", + "mode", + "endpoints", + ], ); validate_section_fields( &mut diagnostics, @@ -1377,6 +1445,28 @@ fn validate_observability_plugin_config( "ATOF file export is not supported on WebAssembly".to_string(), ); } + #[cfg(target_arch = "wasm32")] + if section.enabled && !section.endpoints.is_empty() { + push_policy_diag( + &mut diagnostics, + config.policy.unsupported_value, + "observability.unsupported_value", + Some("atof".to_string()), + Some("endpoints".to_string()), + "ATOF streaming endpoints are not supported on WebAssembly".to_string(), + ); + } + #[cfg(all(not(feature = "atof-streaming"), not(target_arch = "wasm32")))] + if section.enabled && !section.endpoints.is_empty() { + push_policy_diag( + &mut diagnostics, + config.policy.unsupported_value, + "observability.unsupported_value", + Some("atof".to_string()), + Some("endpoints".to_string()), + "ATOF streaming endpoints are not enabled in this build".to_string(), + ); + } } if let Some(section) = &config.atif { validate_atif_values(&mut diagnostics, &config.policy, section); @@ -1497,6 +1587,68 @@ fn validate_atof_values( "ATOF mode must be 'append' or 'overwrite'".to_string(), ); } + for (index, endpoint) in section.endpoints.iter().enumerate() { + validate_atof_endpoint_values(diagnostics, policy, index, endpoint); + } +} + +fn validate_atof_endpoint_values( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + index: usize, + endpoint: &AtofEndpointSectionConfig, +) { + if endpoint.url.trim().is_empty() { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "observability.unsupported_value", + Some("atof".to_string()), + Some(format!("endpoints[{index}].url")), + format!("ATOF endpoints[{index}].url must be non-empty"), + ); + } else if !is_valid_atof_endpoint_url(&endpoint.url) { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "observability.unsupported_value", + Some("atof".to_string()), + Some(format!("endpoints[{index}].url")), + format!("ATOF endpoints[{index}].url must be a valid URL"), + ); + } + if AtofEndpointTransport::parse(&endpoint.transport).is_none() { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "observability.unsupported_value", + Some("atof".to_string()), + Some(format!("endpoints[{index}].transport")), + format!( + "ATOF endpoints[{index}].transport must be 'http_post', 'websocket', or 'ndjson'" + ), + ); + } + if endpoint.timeout_millis == 0 { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "observability.unsupported_value", + Some("atof".to_string()), + Some(format!("endpoints[{index}].timeout_millis")), + format!("ATOF endpoints[{index}].timeout_millis must be greater than 0"), + ); + } +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn is_valid_atof_endpoint_url(url: &str) -> bool { + reqwest::Url::parse(url).is_ok() +} + +#[cfg(any(not(feature = "atof-streaming"), target_arch = "wasm32"))] +fn is_valid_atof_endpoint_url(_url: &str) -> bool { + true } fn validate_atif_values( @@ -1811,6 +1963,10 @@ fn default_atof_mode() -> String { "append".to_string() } +fn default_atof_endpoint_transport() -> String { + "http_post".to_string() +} + fn default_agent_name() -> String { "NeMo Relay".to_string() } diff --git a/crates/core/tests/unit/observability/atof_tests.rs b/crates/core/tests/unit/observability/atof_tests.rs index 51585956..47ba4dd8 100644 --- a/crates/core/tests/unit/observability/atof_tests.rs +++ b/crates/core/tests/unit/observability/atof_tests.rs @@ -12,9 +12,17 @@ use crate::api::runtime::NemoRelayContextState; use crate::api::runtime::global_context; use crate::api::scope::{EmitMarkEventParams, PopScopeParams, PushScopeParams, ScopeType}; use crate::codec::request::{AnnotatedLlmRequest, Message, MessageContent}; +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +use futures_util::StreamExt; use serde_json::{Map, json}; use std::fs; +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +use std::io::{Read, Write}; +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +use std::net::TcpListener; use std::sync::Arc; +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; @@ -223,6 +231,123 @@ fn read_jsonl(path: &Path) -> Vec { .collect() } +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn read_http_request(stream: &mut std::net::TcpStream) -> String { + let mut data = Vec::new(); + let mut buf = [0_u8; 1]; + while !data.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut buf).unwrap(); + data.push(buf[0]); + } + let headers = String::from_utf8_lossy(&data).to_string(); + if let Some(length) = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then_some(value.trim()) + }) + }) + .and_then(|value| value.trim().parse::().ok()) + { + let mut body = vec![0_u8; length]; + stream.read_exact(&mut body).unwrap(); + return String::from_utf8(body).unwrap(); + } + if headers + .lines() + .any(|line| line.eq_ignore_ascii_case("Transfer-Encoding: chunked")) + { + let mut body = Vec::new(); + loop { + let mut size_line = Vec::new(); + loop { + stream.read_exact(&mut buf).unwrap(); + size_line.push(buf[0]); + if size_line.ends_with(b"\r\n") { + break; + } + } + let size_text = String::from_utf8_lossy(&size_line); + let size = usize::from_str_radix(size_text.trim(), 16).unwrap(); + if size == 0 { + let mut trailer = [0_u8; 2]; + stream.read_exact(&mut trailer).unwrap(); + break; + } + let mut chunk = vec![0_u8; size]; + stream.read_exact(&mut chunk).unwrap(); + body.extend(chunk); + let mut crlf = [0_u8; 2]; + stream.read_exact(&mut crlf).unwrap(); + } + return String::from_utf8(body).unwrap(); + } + String::new() +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn start_http_capture_server(expected_requests: usize) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let captures = Arc::new(Mutex::new(Vec::new())); + let thread_captures = Arc::clone(&captures); + std::thread::spawn(move || { + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().unwrap(); + let request_captures = Arc::clone(&thread_captures); + std::thread::spawn(move || { + let body = read_http_request(&mut stream); + request_captures.lock().unwrap().push(body); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .unwrap(); + }); + } + }); + (url, captures) +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn wait_for_captures(captures: &Arc>>, expected: usize) -> Vec { + for _ in 0..100 { + let snapshot = captures.lock().unwrap().clone(); + if snapshot.len() >= expected { + return snapshot; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + captures.lock().unwrap().clone() +} + +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn start_websocket_capture_server( + listener: TcpListener, + captures: Arc>>, + expected_messages: usize, +) { + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = tokio_tungstenite::accept_async(stream).await.unwrap(); + while let Some(message) = websocket.next().await { + let message = message.unwrap(); + if message.is_text() { + captures + .lock() + .unwrap() + .push(message.into_text().unwrap().to_string()); + if captures.lock().unwrap().len() >= expected_messages { + return; + } + } + } + }); + }); +} + #[test] fn default_config_uses_cwd_append_and_timestamped_filename() { let config = AtofExporterConfig::default(); @@ -330,6 +455,125 @@ fn subscriber_writes_canonical_event_jsonl() { ); } +#[test] +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn streaming_endpoints_receive_raw_atof_events_and_file_output_remains() { + let dir = temp_dir("atof-streaming-http"); + let (url, captures) = start_http_capture_server(4); + let exporter = AtofExporter::new( + AtofExporterConfig::new() + .with_output_directory(&dir) + .with_filename("events.jsonl") + .with_endpoint(AtofEndpointConfig::new( + url.clone(), + AtofEndpointTransport::HttpPost, + )) + .with_endpoint(AtofEndpointConfig::new(url, AtofEndpointTransport::Ndjson)), + ) + .unwrap(); + let subscriber = exporter.subscriber(); + + subscriber(&make_mark_event("first")); + subscriber(&make_mark_event("second")); + exporter.force_flush().unwrap(); + subscriber(&make_mark_event("after-flush")); + exporter.shutdown().unwrap(); + + let lines = read_jsonl(exporter.path()); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0]["name"], "first"); + assert_eq!(lines[1]["name"], "second"); + assert_eq!(lines[2]["name"], "after-flush"); + + let bodies = wait_for_captures(&captures, 4); + assert_eq!(bodies.len(), 4, "captured bodies: {bodies:?}"); + let all_streamed = bodies.join(""); + assert!(all_streamed.contains("\"name\":\"first\"")); + assert!(all_streamed.contains("\"name\":\"second\"")); + assert!(all_streamed.contains("\"name\":\"after-flush\"")); + assert_eq!( + all_streamed + .lines() + .filter(|line| line.contains("\"kind\":\"mark\"")) + .count(), + 6, + "three HTTP POST records plus three NDJSON records: {bodies:?}" + ); +} + +#[test] +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn websocket_endpoint_receives_fifo_json_text_events() { + let dir = temp_dir("atof-streaming-websocket"); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let captures = Arc::new(Mutex::new(Vec::new())); + start_websocket_capture_server(listener, Arc::clone(&captures), 2); + + let exporter = AtofExporter::new( + AtofExporterConfig::new() + .with_output_directory(&dir) + .with_filename("events.jsonl") + .with_endpoint(AtofEndpointConfig::new( + url, + AtofEndpointTransport::Websocket, + )), + ) + .unwrap(); + let subscriber = exporter.subscriber(); + + subscriber(&make_mark_event("first")); + subscriber(&make_mark_event("second")); + exporter.force_flush().unwrap(); + + let messages = wait_for_captures(&captures, 2); + assert_eq!(messages.len(), 2); + assert!(messages[0].contains("\"name\":\"first\"")); + assert!(messages[1].contains("\"name\":\"second\"")); +} + +#[test] +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn websocket_flush_drains_events_queued_before_reconnect() { + let dir = temp_dir("atof-streaming-websocket-reconnect"); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let local_addr = listener.local_addr().unwrap(); + drop(listener); + + let exporter = AtofExporter::new( + AtofExporterConfig::new() + .with_output_directory(&dir) + .with_filename("events.jsonl") + .with_endpoint( + AtofEndpointConfig::new( + format!("ws://{local_addr}"), + AtofEndpointTransport::Websocket, + ) + .with_timeout_millis(200), + ), + ) + .unwrap(); + let subscriber = exporter.subscriber(); + + subscriber(&make_mark_event("first")); + subscriber(&make_mark_event("second")); + std::thread::sleep(std::time::Duration::from_millis(300)); + + let listener = TcpListener::bind(local_addr).unwrap(); + listener.set_nonblocking(true).unwrap(); + let captures = Arc::new(Mutex::new(Vec::new())); + start_websocket_capture_server(listener, Arc::clone(&captures), 2); + + exporter.force_flush().unwrap(); + + let messages = wait_for_captures(&captures, 2); + assert_eq!(messages.len(), 2); + assert!(messages[0].contains("\"name\":\"first\"")); + assert!(messages[1].contains("\"name\":\"second\"")); + exporter.shutdown().unwrap(); +} + #[test] fn subscriber_preserves_wire_format_llm_lifecycle_payloads_as_raw_jsonl() { let dir = temp_dir("atof-wire-formats"); @@ -930,6 +1174,80 @@ fn invalid_filename_errors_cleanly() { assert!(matches!(error, AtofExporterError::OpenFile { .. })); } +#[test] +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn invalid_endpoint_config_errors_cleanly() { + let dir = temp_dir("atof-invalid-endpoint"); + + let error = match AtofExporter::new( + AtofExporterConfig::new() + .with_output_directory(&dir) + .with_filename("events.jsonl") + .with_endpoint(AtofEndpointConfig::new( + "not a url", + AtofEndpointTransport::HttpPost, + )), + ) { + Ok(_) => panic!("expected invalid endpoint config error"), + Err(error) => error, + }; + + match error { + AtofExporterError::InvalidEndpoint(message) => { + assert!(message.contains("endpoints[0]")); + } + other => panic!("unexpected error: {other}"), + } +} + +#[test] +#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] +fn invalid_endpoint_scheme_errors_cleanly() { + let dir = temp_dir("atof-invalid-endpoint-scheme"); + + let cases = [ + ( + AtofEndpointTransport::HttpPost, + "ws://localhost:8080/events", + "http_post", + "ws", + ), + ( + AtofEndpointTransport::Ndjson, + "ws://localhost:8080/events", + "ndjson", + "ws", + ), + ( + AtofEndpointTransport::Websocket, + "http://localhost:8080/events", + "websocket", + "http", + ), + ]; + + for (transport, url, transport_name, scheme) in cases { + let error = match AtofExporter::new( + AtofExporterConfig::new() + .with_output_directory(&dir) + .with_filename(format!("{transport_name}.jsonl")) + .with_endpoint(AtofEndpointConfig::new(url, transport)), + ) { + Ok(_) => panic!("expected invalid endpoint scheme error"), + Err(error) => error, + }; + + match error { + AtofExporterError::InvalidEndpoint(message) => { + assert!(message.contains("endpoints[0]")); + assert!(message.contains(transport_name)); + assert!(message.contains(scheme)); + } + other => panic!("unexpected error: {other}"), + } + } +} + #[test] fn force_flush_reports_stored_subscriber_failure() { let dir = temp_dir("atof-stored-failure"); @@ -951,3 +1269,27 @@ fn force_flush_reports_stored_subscriber_failure() { other => panic!("unexpected error: {other}"), } } + +#[test] +fn force_flush_keeps_exporter_open_and_shutdown_is_terminal() { + let dir = temp_dir("atof-flush-not-terminal"); + let exporter = AtofExporter::new( + AtofExporterConfig::new() + .with_output_directory(&dir) + .with_filename("events.jsonl"), + ) + .unwrap(); + let subscriber = exporter.subscriber(); + + subscriber(&make_mark_event("before_flush")); + exporter.force_flush().unwrap(); + subscriber(&make_mark_event("after_flush")); + exporter.shutdown().unwrap(); + subscriber(&make_mark_event("after_shutdown")); + exporter.shutdown().unwrap(); + + let lines = read_jsonl(exporter.path()); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["name"], "before_flush"); + assert_eq!(lines[1]["name"], "after_flush"); +} diff --git a/crates/core/tests/unit/observability/plugin_component_tests.rs b/crates/core/tests/unit/observability/plugin_component_tests.rs index 946c2cf3..e0bbf18e 100644 --- a/crates/core/tests/unit/observability/plugin_component_tests.rs +++ b/crates/core/tests/unit/observability/plugin_component_tests.rs @@ -67,6 +67,11 @@ fn editor_schema_tracks_observability_config_types() { let mode = atof_schema.field("mode").expect("atof mode field"); assert_eq!(mode.kind, EditorFieldKind::Enum); assert_eq!(mode.enum_values, &["append", "overwrite"]); + let endpoints = atof_schema + .field("endpoints") + .expect("atof endpoints field"); + assert_eq!(endpoints.kind, EditorFieldKind::Json); + assert!(endpoints.optional); let otlp = schema .field("openinference") @@ -213,6 +218,7 @@ fn schema_contains_every_supported_observability_option() { "output_directory", "filename", "mode", + "endpoints", "agent_name", "agent_version", "model_name", @@ -458,6 +464,51 @@ fn invalid_shapes_and_strict_policy_are_reported() { ); } +#[test] +fn atof_endpoint_validation_rejects_bad_values() { + let _guard = crate::observability::test_mutex().lock().unwrap(); + reset_runtime(); + + let report = validate_plugin_config(&plugin_config(json!({ + "atof": { + "enabled": true, + "endpoints": [ + {"url": "", "transport": "http_post"}, + {"url": "http://localhost/events", "transport": "bogus"}, + {"url": "http://localhost/events", "transport": "ndjson", "timeout_millis": 0}, + {"url": "not a url", "transport": "http_post"} + ] + } + }))); + + assert!(report.has_errors()); + assert!( + report + .diagnostics + .iter() + .any(|diag| { diag.field.as_deref() == Some("endpoints[0].url") }) + ); + assert!( + report + .diagnostics + .iter() + .any(|diag| { diag.field.as_deref() == Some("endpoints[1].transport") }) + ); + assert!( + report + .diagnostics + .iter() + .any(|diag| { diag.field.as_deref() == Some("endpoints[2].timeout_millis") }) + ); + #[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))] + assert!( + report + .diagnostics + .iter() + .any(|diag| { diag.field.as_deref() == Some("endpoints[3].url") }) + ); +} + #[test] fn initialization_fails_for_invalid_enabled_file_exporters() { let _guard = crate::observability::test_mutex().lock().unwrap(); diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index 7c5ba010..4a019f86 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -17,7 +17,7 @@ workspace = true crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] -nemo-relay = { workspace = true, features = ["otel", "openinference"] } +nemo-relay = { workspace = true, features = ["atof-streaming", "otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } chrono = "0.4" libc = "0.2" diff --git a/crates/ffi/nemo_relay.h b/crates/ffi/nemo_relay.h index a5d5fa3d..d7fdb94d 100644 --- a/crates/ffi/nemo_relay.h +++ b/crates/ffi/nemo_relay.h @@ -964,6 +964,19 @@ NemoRelayStatus nemo_relay_atof_exporter_create(const char *output_directory, const char *filename, struct FfiAtofExporter **out); +/** + * Creates a new ATOF exporter from a JSON config object. + * + * # Parameters + * - `config_json`: JSON object matching `AtofExporterConfig`. + * - `out`: On success, receives a heap-allocated `FfiAtofExporter`. + * + * # Safety + * `config_json` must be a valid C string. `out` must be valid. + */ +NemoRelayStatus nemo_relay_atof_exporter_create_from_json(const char *config_json, + struct FfiAtofExporter **out); + /** * Registers the ATOF exporter as an event subscriber. * diff --git a/crates/ffi/src/api/observability.rs b/crates/ffi/src/api/observability.rs index e0d92796..2713c394 100644 --- a/crates/ffi/src/api/observability.rs +++ b/crates/ffi/src/api/observability.rs @@ -23,6 +23,7 @@ fn status_from_atof_error(error: &AtofExporterError) -> NemoRelayStatus { set_last_error(&error.to_string()); match error { AtofExporterError::Runtime(error) => status_from_error(error), + AtofExporterError::InvalidEndpoint(_) => NemoRelayStatus::InvalidArg, _ => NemoRelayStatus::Internal, } } @@ -342,6 +343,50 @@ pub unsafe extern "C" fn nemo_relay_atof_exporter_create( } } +/// Creates a new ATOF exporter from a JSON config object. +/// +/// # Parameters +/// - `config_json`: JSON object matching `AtofExporterConfig`. +/// - `out`: On success, receives a heap-allocated `FfiAtofExporter`. +/// +/// # Safety +/// `config_json` must be a valid C string. `out` must be valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn nemo_relay_atof_exporter_create_from_json( + config_json: *const c_char, + out: *mut *mut FfiAtofExporter, +) -> NemoRelayStatus { + clear_last_error(); + if let Err(status) = required_out_ptr(out) { + return status; + } + let config_json = match c_str_to_string(config_json) { + Ok(config_json) => config_json, + Err(status) => return status, + }; + let config_value = match serde_json::from_str(&config_json) { + Ok(config_value) => config_value, + Err(error) => { + set_last_error(&format!("invalid JSON: {error}")); + return NemoRelayStatus::InvalidJson; + } + }; + let config = match serde_json::from_value::(config_value) { + Ok(config) => config, + Err(error) => { + set_last_error(&error.to_string()); + return NemoRelayStatus::InvalidJson; + } + }; + match AtofExporter::new(config) { + Ok(exporter) => { + unsafe { *out = Box::into_raw(Box::new(FfiAtofExporter(exporter))) }; + NemoRelayStatus::Ok + } + Err(error) => status_from_atof_error(&error), + } +} + /// Registers the ATOF exporter as an event subscriber. /// /// # Safety diff --git a/crates/ffi/tests/integration/api_tests.rs b/crates/ffi/tests/integration/api_tests.rs index 1eb04f77..5a0af1dd 100644 --- a/crates/ffi/tests/integration/api_tests.rs +++ b/crates/ffi/tests/integration/api_tests.rs @@ -764,3 +764,42 @@ fn atof_exporter_writes_raw_jsonl_events() { nemo_relay_scope_stack_free(stack); } } + +#[test] +fn atof_exporter_create_from_json_reports_string_statuses() { + let _guard = TEST_MUTEX.lock().unwrap(); + let mut exporter: *mut FfiAtofExporter = ptr::null_mut(); + + assert_eq!( + unsafe { api::nemo_relay_atof_exporter_create_from_json(ptr::null(), &mut exporter) }, + NemoRelayStatus::NullPointer + ); + + let invalid_utf8 = [0xff_u8, 0]; + assert_eq!( + unsafe { + api::nemo_relay_atof_exporter_create_from_json( + invalid_utf8.as_ptr().cast(), + &mut exporter, + ) + }, + NemoRelayStatus::InvalidUtf8 + ); + + let invalid_json = cstring("{"); + assert_eq!( + unsafe { + api::nemo_relay_atof_exporter_create_from_json(invalid_json.as_ptr(), &mut exporter) + }, + NemoRelayStatus::InvalidJson + ); + + let invalid_endpoint = + cstring(r#"{"endpoints":[{"url":"http://localhost/events","transport":"websocket"}]}"#); + assert_eq!( + unsafe { + api::nemo_relay_atof_exporter_create_from_json(invalid_endpoint.as_ptr(), &mut exporter) + }, + NemoRelayStatus::InvalidArg + ); +} diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 878a8513..82ec92ec 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["cdylib"] test = false [dependencies] -nemo-relay = { workspace = true, features = ["otel", "openinference"] } +nemo-relay = { workspace = true, features = ["atof-streaming", "otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } chrono = "0.4" napi = { version = "2", features = ["napi6", "async", "serde-json", "tokio_rt"] } diff --git a/crates/node/observability.d.ts b/crates/node/observability.d.ts index 18c20bca..f31341d3 100644 --- a/crates/node/observability.d.ts +++ b/crates/node/observability.d.ts @@ -11,6 +11,14 @@ export interface AtofConfig { output_directory?: string; filename?: string; mode?: 'append' | 'overwrite' | string; + endpoints?: AtofEndpointConfig[]; +} + +export interface AtofEndpointConfig { + url: string; + transport?: 'http_post' | 'websocket' | 'ndjson' | string; + headers?: Record; + timeout_millis?: number; } export interface S3StorageConfig { diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index b3930001..6194490a 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -160,6 +160,29 @@ fn build_atof_config( }; config = config.with_mode(mode); } + let mut endpoints = Vec::new(); + for endpoint in options.endpoints.unwrap_or_default() { + let transport = endpoint + .transport + .unwrap_or_else(|| "http_post".to_string()); + let Some(transport) = + nemo_relay::observability::atof::AtofEndpointTransport::parse(&transport) + else { + return Err(napi::Error::from_reason( + "endpoint transport must be 'http_post', 'websocket', or 'ndjson'", + )); + }; + let mut endpoint_config = + nemo_relay::observability::atof::AtofEndpointConfig::new(endpoint.url, transport); + if let Some(timeout_millis) = endpoint.timeout_millis { + endpoint_config = endpoint_config.with_timeout_millis(timeout_millis.into()); + } + for (key, value) in parse_string_map(endpoint.headers, "endpoint.headers")? { + endpoint_config = endpoint_config.with_header(key, value); + } + endpoints.push(endpoint_config); + } + config = config.with_endpoints(endpoints); Ok(config) } @@ -2990,6 +3013,22 @@ pub struct AtofExporterConfig { pub mode: Option, /// Output filename. Defaults to `nemo-relay-events-YYYY-MM-DD-HH.MM.SS.jsonl`. pub filename: Option, + /// Streaming endpoints that receive every raw ATOF event. + pub endpoints: Option>, +} + +/// Mutable configuration object for one ATOF streaming endpoint. +#[napi(object)] +#[derive(Default)] +pub struct AtofEndpointConfig { + /// Endpoint URL. + pub url: String, + /// `"http_post"` (default), `"websocket"`, or `"ndjson"`. + pub transport: Option, + /// Extra endpoint headers as string key/value pairs. + pub headers: Option, + /// Per-endpoint timeout in milliseconds. + pub timeout_millis: Option, } /// Filesystem-backed Agent Trajectory Observability Format (ATOF) JSONL event exporter. diff --git a/crates/node/tests/atof_tests.mjs b/crates/node/tests/atof_tests.mjs index acaba969..bd396603 100644 --- a/crates/node/tests/atof_tests.mjs +++ b/crates/node/tests/atof_tests.mjs @@ -30,6 +30,14 @@ describe('AtofExporter', () => { exporter.shutdown(); assert.throws(() => new AtofExporter({ mode: 'invalid' }), /mode must be/i); + assert.throws( + () => + new AtofExporter({ + outputDirectory: tempDir('node-atof-invalid-endpoint'), + endpoints: [{ url: 'http://localhost:8080/events', transport: 'bogus' }], + }), + /endpoint transport/i, + ); }); it('writes raw ATOF JSONL events and supports lifecycle methods', () => { diff --git a/crates/node/tests/observability_plugin_tests.mjs b/crates/node/tests/observability_plugin_tests.mjs index c73d4ead..e999831a 100644 --- a/crates/node/tests/observability_plugin_tests.mjs +++ b/crates/node/tests/observability_plugin_tests.mjs @@ -56,6 +56,28 @@ describe('observability plugin helpers', () => { assert.deepEqual(report.diagnostics.map((diagnostic) => diagnostic.field).sort(), ['filename_template', 'mode']); }); + it('serializes ATOF streaming endpoints', () => { + const config = observability.atofConfig({ + endpoints: [ + { + url: 'http://localhost:8080/events', + transport: 'http_post', + headers: { 'X-Test': 'yes' }, + timeout_millis: 1000, + }, + ], + }); + + assert.deepEqual(config.endpoints, [ + { + url: 'http://localhost:8080/events', + transport: 'http_post', + headers: { 'X-Test': 'yes' }, + timeout_millis: 1000, + }, + ]); + }); + it('passes through mixed ATIF remote storage config', () => { const s3 = { type: 's3', diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index 6f20de35..4861d109 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -18,7 +18,7 @@ name = "_native" crate-type = ["cdylib", "rlib"] [dependencies] -nemo-relay = { workspace = true, features = ["otel", "openinference"] } +nemo-relay = { workspace = true, features = ["atof-streaming", "otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } pyo3 = { version = "0.28.2", features = ["abi3", "abi3-py311", "experimental-inspect", "macros"] } pyo3-async-runtimes = { version = "0.28.0", features = ["tokio-runtime"] } diff --git a/crates/python/src/py_types/mod.rs b/crates/python/src/py_types/mod.rs index 2f00ecb6..f4e01853 100644 --- a/crates/python/src/py_types/mod.rs +++ b/crates/python/src/py_types/mod.rs @@ -138,6 +138,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/crates/python/src/py_types/observability.rs b/crates/python/src/py_types/observability.rs index 426a812b..5bdf6208 100644 --- a/crates/python/src/py_types/observability.rs +++ b/crates/python/src/py_types/observability.rs @@ -162,6 +162,72 @@ impl From for PyAtofExporterM } } +/// Mutable configuration object for an ATOF streaming endpoint. +/// +/// Configures a remote endpoint URL, transport (`http_post`, `websocket`, or +/// `ndjson`), optional string headers, and a positive timeout in milliseconds. +#[pyclass(name = "AtofEndpointConfig", from_py_object)] +#[derive(Clone)] +pub struct PyAtofEndpointConfig { + #[pyo3(get, set)] + pub(crate) url: String, + #[pyo3(get, set)] + pub(crate) transport: String, + #[pyo3(get, set)] + pub(crate) headers: HashMap, + #[pyo3(get, set)] + pub(crate) timeout_millis: u64, +} + +impl PyAtofEndpointConfig { + fn to_rust_config(&self) -> PyResult { + let Some(transport) = + nemo_relay::observability::atof::AtofEndpointTransport::parse(&self.transport) + else { + return Err(pyo3::exceptions::PyValueError::new_err( + "endpoint transport must be 'http_post', 'websocket', or 'ndjson'", + )); + }; + let mut config = + nemo_relay::observability::atof::AtofEndpointConfig::new(self.url.clone(), transport) + .with_timeout_millis(self.timeout_millis); + for (key, value) in &self.headers { + config = config.with_header(key.clone(), value.clone()); + } + Ok(config) + } +} + +#[pymethods] +impl PyAtofEndpointConfig { + #[new] + #[pyo3(signature = (url, *, transport="http_post".to_string(), headers=None, timeout_millis=3000))] + pub(crate) fn new( + url: String, + transport: String, + headers: Option<&Bound<'_, PyAny>>, + timeout_millis: u64, + ) -> PyResult { + let headers = match headers { + Some(headers) if !headers.is_none() => py_string_map(headers, "headers")?, + _ => HashMap::new(), + }; + Ok(Self { + url, + transport, + headers, + timeout_millis, + }) + } + + pub(crate) fn __repr__(&self) -> String { + format!( + "", + self.transport, self.url + ) + } +} + /// Mutable configuration object for the filesystem-backed ATOF JSONL exporter. #[pyclass(name = "AtofExporterConfig")] pub struct PyAtofExporterConfig { @@ -171,14 +237,22 @@ pub struct PyAtofExporterConfig { pub(crate) mode: PyAtofExporterMode, #[pyo3(get, set)] pub(crate) filename: String, + #[pyo3(get, set)] + pub(crate) endpoints: Vec, } impl PyAtofExporterConfig { - fn to_rust_config(&self) -> nemo_relay::observability::atof::AtofExporterConfig { - nemo_relay::observability::atof::AtofExporterConfig::new() + fn to_rust_config(&self) -> PyResult { + let endpoints = self + .endpoints + .iter() + .map(PyAtofEndpointConfig::to_rust_config) + .collect::>>()?; + Ok(nemo_relay::observability::atof::AtofExporterConfig::new() .with_output_directory(PathBuf::from(self.output_directory.clone())) .with_mode(self.mode.clone().into()) .with_filename(self.filename.clone()) + .with_endpoints(endpoints)) } } @@ -191,6 +265,7 @@ impl PyAtofExporterConfig { output_directory: config.output_directory.to_string_lossy().into_owned(), mode: config.mode.into(), filename: config.filename, + endpoints: Vec::new(), } } @@ -215,7 +290,7 @@ pub struct PyAtofExporter { impl PyAtofExporter { #[new] pub(crate) fn new(config: PyRef<'_, PyAtofExporterConfig>) -> PyResult { - let inner = nemo_relay::observability::atof::AtofExporter::new(config.to_rust_config()) + let inner = nemo_relay::observability::atof::AtofExporter::new(config.to_rust_config()?) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(Self { inner }) } diff --git a/crates/wasm/wrappers/esm/observability.d.ts b/crates/wasm/wrappers/esm/observability.d.ts index 6f45069c..6a4d9a8c 100644 --- a/crates/wasm/wrappers/esm/observability.d.ts +++ b/crates/wasm/wrappers/esm/observability.d.ts @@ -11,6 +11,14 @@ export interface AtofConfig { output_directory?: string; filename?: string; mode?: 'append' | 'overwrite' | string; + endpoints?: AtofEndpointConfig[]; +} + +export interface AtofEndpointConfig { + url: string; + transport?: 'http_post' | 'websocket' | 'ndjson' | string; + headers?: Record; + timeout_millis?: number; } export interface AtifConfig { diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 866d80d9..815e8c56 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -241,9 +241,9 @@ Common validation failures include: Format (ATIF) filename template that does not contain `{session_id}`. Use `nemo-relay doctor` to inspect the resolved gateway configuration and plugin -diagnostics. For Observability, doctor also reports enabled exporter sections and -checks writable file exporter directories or reachable OTLP endpoints when those -settings are present. +diagnostics. For Observability, doctor also reports enabled exporter sections, +checks writable file exporter directories, probes configured ATOF streaming +endpoints, and checks reachable OTLP endpoints when those settings are present. ## Relationship To `config.toml` diff --git a/docs/observability-plugin/atof.mdx b/docs/observability-plugin/atof.mdx index df705473..3757aa81 100644 --- a/docs/observability-plugin/atof.mdx +++ b/docs/observability-plugin/atof.mdx @@ -8,11 +8,13 @@ SPDX-License-Identifier: Apache-2.0 */} Use the `atof` section when you want the raw Agent Trajectory Observability -Format (ATOF) `0.1` event stream written as JSONL. +Format (ATOF) `0.1` event stream written as JSONL or streamed to raw-event +collectors. ATOF JSONL export is useful for local debugging, offline inspection, and preserving the canonical event stream before it is translated into another -format. +format. Streaming endpoints are useful when a collector wants the same raw +event shape in near real time. ## `plugins.toml` Example @@ -31,10 +33,16 @@ enabled = true output_directory = "logs" filename = "events.jsonl" mode = "overwrite" + +[[components.config.atof.endpoints]] +url = "http://localhost:8080/events" +transport = "http_post" +timeout_millis = 3000 ``` This configuration registers the plugin-managed ATOF exporter and writes one -JSON object per lifecycle event to `logs/events.jsonl`. +JSON object per lifecycle event to `logs/events.jsonl`. It also sends each raw +ATOF event to the configured endpoint. ## Fields @@ -44,6 +52,32 @@ JSON object per lifecycle event to `logs/events.jsonl`. | `output_directory` | Current working directory | Directory containing the JSONL file. | | `filename` | Timestamped `nemo-relay-events-*.jsonl` | Explicit output filename. | | `mode` | `append` | `append` or `overwrite`. | +| `endpoints` | `[]` | Optional streaming destinations. File output remains active when endpoints are configured. | + +## Streaming Endpoints + +Each endpoint receives the same raw ATOF JSON object that the file exporter +writes as one JSONL line. Endpoints are independent: a failed endpoint is +skipped or retried without blocking file output or other endpoints. + +| Field | Default | Notes | +|---|---|---| +| `url` | Required | Endpoint URL. | +| `transport` | `http_post` | `http_post`, `websocket`, or `ndjson`. | +| `headers` | `{}` | String-to-string headers for requests or handshakes. | +| `timeout_millis` | `3000` | Per-endpoint timeout. Must be greater than `0`. | + +- `http_post` sends each event as one JSONL record in an HTTP `POST` request + with `Content-Type: application/x-ndjson`. Any `2xx` response is treated as + success. +- `websocket` opens one connection and sends each event as one JSON text + message. +- `ndjson` opens one long-lived HTTP upload and writes each event as one + newline-delimited JSON record. + +`force_flush()` flushes file output and drains queued endpoint events without +closing streaming connections. `shutdown()` is terminal: it flushes pending +work, closes streaming connections, and makes later events no-op. ## Expected Output @@ -63,7 +97,12 @@ exporter lifecycle. ```python from nemo_relay import plugin -from nemo_relay.observability import AtofConfig, ComponentSpec, ObservabilityConfig +from nemo_relay.observability import ( + AtofConfig, + AtofEndpointConfig, + ComponentSpec, + ObservabilityConfig, +) config = plugin.PluginConfig( components=[ @@ -74,6 +113,12 @@ config = plugin.PluginConfig( output_directory="logs", filename="events.jsonl", mode="overwrite", + endpoints=[ + AtofEndpointConfig( + url="http://localhost:8080/events", + transport="http_post", + ) + ], ) ) ) @@ -109,6 +154,12 @@ await plugin.initialize({ output_directory: "logs", filename: "events.jsonl", mode: "overwrite", + endpoints: [ + { + url: "http://localhost:8080/events", + transport: "http_post", + }, + ], }), }), ], @@ -126,7 +177,7 @@ try { ```rust use nemo_relay::observability::plugin_component::{ - AtofSectionConfig, ComponentSpec, ObservabilityConfig, + AtofEndpointSectionConfig, AtofSectionConfig, ComponentSpec, ObservabilityConfig, }; use nemo_relay::plugin::{initialize_plugins, validate_plugin_config, PluginConfig}; @@ -136,6 +187,12 @@ let component = ComponentSpec::new(ObservabilityConfig { output_directory: Some("logs".into()), filename: Some("events.jsonl".into()), mode: "overwrite".into(), + endpoints: vec![AtofEndpointSectionConfig { + url: "http://localhost:8080/events".into(), + transport: "http_post".into(), + headers: Default::default(), + timeout_millis: 3000, + }], }), ..ObservabilityConfig::default() }); @@ -164,12 +221,15 @@ subscriber name or explicit registration window. ```python -from nemo_relay import AtofExporter, AtofExporterConfig, AtofExporterMode +from nemo_relay import AtofEndpointConfig, AtofExporter, AtofExporterConfig, AtofExporterMode config = AtofExporterConfig() config.output_directory = "logs" config.filename = "events.jsonl" config.mode = AtofExporterMode.Overwrite +config.endpoints = [ + AtofEndpointConfig("http://localhost:8080/events", transport="http_post") +] exporter = AtofExporter(config) exporter.register("atof-exporter") @@ -191,6 +251,7 @@ const exporter = new AtofExporter({ outputDirectory: "logs", filename: "events.jsonl", mode: "overwrite", + endpoints: [{ url: "http://localhost:8080/events", transport: "http_post" }], }); exporter.register("atof-exporter"); @@ -209,13 +270,17 @@ try { ```rust use nemo_relay::observability::atof::{ - AtofExporter, AtofExporterConfig, AtofExporterMode, + AtofEndpointConfig, AtofEndpointTransport, AtofExporter, AtofExporterConfig, AtofExporterMode, }; let config = AtofExporterConfig::new() .with_output_directory("logs") .with_filename("events.jsonl") - .with_mode(AtofExporterMode::Overwrite); + .with_mode(AtofExporterMode::Overwrite) + .with_endpoint(AtofEndpointConfig::new( + "http://localhost:8080/events", + AtofEndpointTransport::HttpPost, + )); let exporter = AtofExporter::new(config)?; exporter.register("atof-exporter")?; @@ -233,5 +298,9 @@ exporter.shutdown()?; ## Common Validation Failures - `mode` is not `append` or `overwrite`. +- `endpoints[i].url` is empty, `endpoints[i].transport` is not supported, or + `endpoints[i].timeout_millis` is `0`. - The output directory is not writable at runtime. +- `nemo-relay doctor` cannot deliver its synthetic ATOF mark probe to a + configured endpoint. - ATOF is enabled in a target that cannot access the native filesystem. diff --git a/docs/observability-plugin/configuration.mdx b/docs/observability-plugin/configuration.mdx index 2350814c..5feded6b 100644 --- a/docs/observability-plugin/configuration.mdx +++ b/docs/observability-plugin/configuration.mdx @@ -68,6 +68,14 @@ output_directory = "logs" filename = "events.jsonl" mode = "overwrite" +[[components.config.atof.endpoints]] +url = "http://localhost:8080/events" +transport = "http_post" +timeout_millis = 3000 + +[components.config.atof.endpoints.headers] +authorization = "Bearer " + [components.config.atif] enabled = true output_directory = "logs" @@ -244,8 +252,8 @@ try { ```rust use nemo_relay::observability::plugin_component::{ - AtifSectionConfig, AtofSectionConfig, ComponentSpec, ObservabilityConfig, - OtlpSectionConfig, + AtifSectionConfig, AtofEndpointSectionConfig, AtofSectionConfig, ComponentSpec, + ObservabilityConfig, OtlpSectionConfig, }; use nemo_relay::plugin::{initialize_plugins, validate_plugin_config, PluginConfig}; @@ -255,6 +263,12 @@ let component = ComponentSpec::new(ObservabilityConfig { output_directory: Some("logs".into()), filename: Some("events.jsonl".into()), mode: "overwrite".into(), + endpoints: vec![AtofEndpointSectionConfig { + url: "http://localhost:8080/events".into(), + transport: "http_post".into(), + headers: [("authorization".into(), "Bearer ".into())].into(), + timeout_millis: 3000, + }], }), atif: Some(AtifSectionConfig { enabled: true, @@ -304,13 +318,15 @@ let active = initialize_plugins(config).await?; ## Validation And Teardown Validate plugin configuration before activating it. The plugin reports -unsupported transports, unsupported ATOF modes, unsafe ATIF filename templates, -unknown fields according to policy, and enabled exporters that are unavailable -in the current build or target. +unsupported transports, unsupported ATOF modes, invalid ATOF streaming endpoint +URLs, non-string endpoint headers, non-positive endpoint timeouts, unsafe ATIF +filename templates, unknown fields according to policy, and enabled exporters +that are unavailable in the current build or target. Call `plugin.clear()` or `clear_plugin_configuration()` during teardown. Clearing the plugin config deregisters inferred subscribers, flushes file -exporters, and shuts down owned OTLP subscribers. +exporters, drains and closes ATOF streaming endpoints, and shuts down owned OTLP +subscribers. Use manual subscriber/exporter APIs instead of the plugin when you need custom subscriber names, explicit per-run exporter objects, or direct control over the diff --git a/docs/resources/troubleshooting/trace-incident-runbook.mdx b/docs/resources/troubleshooting/trace-incident-runbook.mdx index 700840f1..4369c735 100644 --- a/docs/resources/troubleshooting/trace-incident-runbook.mdx +++ b/docs/resources/troubleshooting/trace-incident-runbook.mdx @@ -140,6 +140,15 @@ For file or trajectory export, confirm these settings: - ATIF export is scoped to the intended agent root and does not mix concurrent root scopes. +For ATOF streaming endpoints, confirm these settings: + +- Endpoint URLs, transports, headers, and timeouts match the downstream + collector. +- `nemo-relay doctor` can send the synthetic + `nemo_relay.doctor.atof_probe` mark event to each configured endpoint. +- A failed streaming endpoint is isolated from file output and from other + configured endpoints. + For OpenTelemetry or OpenInference export, confirm these settings: - The OpenTelemetry Protocol (OTLP) endpoint, headers, credentials, and network diff --git a/go/nemo_relay/atof_test.go b/go/nemo_relay/atof_test.go index f7ea11af..96a3bc1f 100644 --- a/go/nemo_relay/atof_test.go +++ b/go/nemo_relay/atof_test.go @@ -26,6 +26,18 @@ func TestNewAtofExporterConfigDefaults(t *testing.T) { if config.Filename != "" { t.Fatalf("expected empty filename default override, got %q", config.Filename) } + if len(config.Endpoints) != 0 { + t.Fatalf("expected no streaming endpoints by default, got %#v", config.Endpoints) + } + config.Endpoints = []AtofEndpointConfig{{ + URL: "http://localhost:8080/events", + Transport: AtofEndpointTransportHTTPPost, + Headers: map[string]string{"X-Test": "yes"}, + TimeoutMillis: 1000, + }} + if config.Endpoints[0].Transport != AtofEndpointTransportHTTPPost { + t.Fatalf("unexpected endpoint config: %#v", config.Endpoints[0]) + } } func TestAtofExporterLifecycleWritesRawJSONL(t *testing.T) { diff --git a/go/nemo_relay/nemo_relay.go b/go/nemo_relay/nemo_relay.go index d8677b4d..430ea6ba 100644 --- a/go/nemo_relay/nemo_relay.go +++ b/go/nemo_relay/nemo_relay.go @@ -228,6 +228,7 @@ extern void nemo_relay_atif_exporter_free(void*); // ATOF JSONL exporter extern int32_t nemo_relay_atof_exporter_create(const char*, const char*, const char*, void**); +extern int32_t nemo_relay_atof_exporter_create_from_json(const char*, void**); extern int32_t nemo_relay_atof_exporter_register(const void*, const char*); extern int32_t nemo_relay_atof_exporter_deregister(const char*); extern int32_t nemo_relay_atof_exporter_force_flush(const void*); @@ -323,6 +324,17 @@ var ( if config.Mode == "" { config.Mode = AtofExporterModeAppend } + if len(config.Endpoints) > 0 { + payload, err := json.Marshal(config) + if err != nil { + return nil, err + } + cConfig := C.CString(string(payload)) + defer C.free(unsafe.Pointer(cConfig)) + var ptr unsafe.Pointer + status := C.nemo_relay_atof_exporter_create_from_json(cConfig, &ptr) + return checkedValue(int32(status), &AtofExporter{ptr: ptr}) + } var cOutputDirectory *C.char if config.OutputDirectory != "" { @@ -1613,9 +1625,30 @@ const ( // AtofExporterConfig configures the filesystem-backed ATOF JSONL exporter. type AtofExporterConfig struct { - OutputDirectory string - Mode AtofExporterMode - Filename string + OutputDirectory string `json:"output_directory,omitempty"` + Mode AtofExporterMode `json:"mode,omitempty"` + Filename string `json:"filename,omitempty"` + Endpoints []AtofEndpointConfig `json:"endpoints,omitempty"` +} + +// AtofEndpointTransport controls how an ATOF streaming endpoint receives events. +type AtofEndpointTransport string + +const ( + // AtofEndpointTransportHTTPPost sends each event as one HTTP POST JSONL record. + AtofEndpointTransportHTTPPost AtofEndpointTransport = "http_post" + // AtofEndpointTransportWebsocket sends each event as one WebSocket JSON text message. + AtofEndpointTransportWebsocket AtofEndpointTransport = "websocket" + // AtofEndpointTransportNDJSON sends events over one long-lived HTTP NDJSON upload. + AtofEndpointTransportNDJSON AtofEndpointTransport = "ndjson" +) + +// AtofEndpointConfig configures one streaming destination for raw ATOF events. +type AtofEndpointConfig struct { + URL string `json:"url"` + Transport AtofEndpointTransport `json:"transport,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + TimeoutMillis uint64 `json:"timeout_millis,omitempty"` } // NewAtofExporterConfig returns a config initialized with native defaults. diff --git a/go/nemo_relay/observability_plugin.go b/go/nemo_relay/observability_plugin.go index 2b72b8d2..ae9f8395 100644 --- a/go/nemo_relay/observability_plugin.go +++ b/go/nemo_relay/observability_plugin.go @@ -18,10 +18,19 @@ type ObservabilityConfig struct { // ObservabilityAtofConfig configures filesystem-backed raw ATOF JSONL export. type ObservabilityAtofConfig struct { - Enabled bool `json:"enabled,omitempty"` - OutputDirectory string `json:"output_directory,omitempty"` - Filename string `json:"filename,omitempty"` - Mode string `json:"mode,omitempty"` + Enabled bool `json:"enabled,omitempty"` + OutputDirectory string `json:"output_directory,omitempty"` + Filename string `json:"filename,omitempty"` + Mode string `json:"mode,omitempty"` + Endpoints []ObservabilityAtofEndpoint `json:"endpoints,omitempty"` +} + +// ObservabilityAtofEndpoint configures one streaming destination for raw ATOF events. +type ObservabilityAtofEndpoint struct { + URL string `json:"url"` + Transport string `json:"transport,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + TimeoutMillis uint64 `json:"timeout_millis,omitempty"` } // ObservabilityAtifConfig configures per-top-level-agent ATIF file export. diff --git a/go/nemo_relay/observability_plugin_test.go b/go/nemo_relay/observability_plugin_test.go index f5a7fbe4..42aac60d 100644 --- a/go/nemo_relay/observability_plugin_test.go +++ b/go/nemo_relay/observability_plugin_test.go @@ -31,6 +31,12 @@ func TestObservabilityConfigHelpers(t *testing.T) { if atof.Enabled || atof.Mode != "append" { t.Fatalf("unexpected ATOF defaults: %#v", atof) } + atof.Endpoints = []ObservabilityAtofEndpoint{{ + URL: "http://localhost:8080/events", + Transport: "http_post", + Headers: map[string]string{"X-Test": "yes"}, + TimeoutMillis: 1000, + }} atif := NewObservabilityAtifConfig() if atif.Enabled || atif.AgentName != "NeMo Relay" || atif.ModelName != "unknown" || atif.FilenameTemplate != "nemo-relay-atif-{session_id}.json" { t.Fatalf("unexpected ATIF defaults: %#v", atif) @@ -48,6 +54,10 @@ func TestObservabilityConfigHelpers(t *testing.T) { if _, ok := wrapped.Config["atof"].(map[string]any); !ok { t.Fatalf("expected serialized ATOF config object, got %#v", wrapped.Config) } + atofConfig := wrapped.Config["atof"].(map[string]any) + if _, ok := atofConfig["endpoints"].([]any); !ok { + t.Fatalf("expected serialized ATOF endpoints, got %#v", atofConfig) + } } func TestObservabilityPluginAtofAndAtifFiles(t *testing.T) { diff --git a/python/nemo_relay/__init__.py b/python/nemo_relay/__init__.py index 3e27a98b..9326edc0 100644 --- a/python/nemo_relay/__init__.py +++ b/python/nemo_relay/__init__.py @@ -87,6 +87,7 @@ async def main(): AnnotatedLLMRequest, AnnotatedLLMResponse, AtifExporter, + AtofEndpointConfig, AtofExporter, AtofExporterConfig, AtofExporterMode, @@ -454,6 +455,7 @@ def worker() -> None: "AnnotatedLLMRequest", "AnnotatedLLMResponse", "AtifExporter", + "AtofEndpointConfig", "AtofExporterMode", "AtofExporterConfig", "AtofExporter", diff --git a/python/nemo_relay/__init__.pyi b/python/nemo_relay/__init__.pyi index 5ddde9b6..902654ce 100644 --- a/python/nemo_relay/__init__.pyi +++ b/python/nemo_relay/__init__.pyi @@ -46,6 +46,9 @@ from nemo_relay._native import ( from nemo_relay._native import ( AtifExporter as AtifExporter, ) +from nemo_relay._native import ( + AtofEndpointConfig as AtofEndpointConfig, +) from nemo_relay._native import ( AtofExporter as AtofExporter, ) diff --git a/python/nemo_relay/_native.pyi b/python/nemo_relay/_native.pyi index 642ca3db..8a0ea194 100644 --- a/python/nemo_relay/_native.pyi +++ b/python/nemo_relay/_native.pyi @@ -730,12 +730,35 @@ class AtofExporterMode: Append: ClassVar[AtofExporterMode] Overwrite: ClassVar[AtofExporterMode] +class AtofEndpointConfig: + """Streaming destination for raw ATOF events.""" + + url: str + transport: str + headers: dict[str, str] + timeout_millis: int + + def __init__( + self, + url: str, + *, + transport: str = "http_post", + headers: dict[str, str] | None = None, + timeout_millis: int = 3000, + ) -> None: + """Create an ATOF streaming endpoint config. + + ``headers=None`` is converted to an empty dict; the instance field is + always non-optional. + """ + class AtofExporterConfig: """Mutable configuration for the filesystem-backed ATOF JSONL exporter.""" output_directory: str mode: AtofExporterMode filename: str + endpoints: list[AtofEndpointConfig] def __init__(self) -> None: """Create an ATOF exporter config with native defaults.""" diff --git a/python/nemo_relay/observability.py b/python/nemo_relay/observability.py index be36b9e7..a067f4cc 100644 --- a/python/nemo_relay/observability.py +++ b/python/nemo_relay/observability.py @@ -52,6 +52,27 @@ def to_dict(self) -> JsonObject: } +@dataclass(slots=True) +class AtofEndpointConfig: + """Streaming destination for raw ATOF events.""" + + url: str + transport: Literal["http_post", "websocket", "ndjson"] = "http_post" + headers: dict[str, str] = field(default_factory=dict) + timeout_millis: int = 3000 + + def to_dict(self) -> JsonObject: + """Serialize this ATOF endpoint config to the canonical JSON object shape.""" + return _normalize_object( + { + "url": self.url, + "transport": self.transport, + "headers": self.headers, + "timeout_millis": self.timeout_millis, + } + ) + + @dataclass(slots=True) class AtofConfig: """Filesystem-backed raw ATOF JSONL export settings.""" @@ -60,6 +81,7 @@ class AtofConfig: output_directory: str | None = None filename: str | None = None mode: Literal["append", "overwrite"] = "append" + endpoints: list[AtofEndpointConfig] | None = None def to_dict(self) -> JsonObject: """Serialize this ATOF config to the canonical JSON object shape.""" @@ -69,6 +91,7 @@ def to_dict(self) -> JsonObject: "output_directory": self.output_directory, "filename": self.filename, "mode": self.mode, + "endpoints": self.endpoints, } ) @@ -243,6 +266,7 @@ def to_dict(self) -> JsonObject: __all__ = [ "ConfigPolicy", + "AtofEndpointConfig", "AtofConfig", "AtifConfig", "HttpStorageConfig", diff --git a/python/nemo_relay/observability.pyi b/python/nemo_relay/observability.pyi index 9685ffc0..fa76d502 100644 --- a/python/nemo_relay/observability.pyi +++ b/python/nemo_relay/observability.pyi @@ -17,12 +17,21 @@ class ConfigPolicy: unsupported_value: UnsupportedBehavior = ... def to_dict(self) -> JsonObject: ... +@dataclass(slots=True) +class AtofEndpointConfig: + url: str = ... + transport: Literal["http_post", "websocket", "ndjson"] = ... + headers: dict[str, str] = field(default_factory=dict) + timeout_millis: int = ... + def to_dict(self) -> JsonObject: ... + @dataclass(slots=True) class AtofConfig: enabled: bool = ... output_directory: str | None = ... filename: str | None = ... mode: Literal["append", "overwrite"] = ... + endpoints: list[AtofEndpointConfig] | None = ... def to_dict(self) -> JsonObject: ... @dataclass(slots=True) diff --git a/python/tests/test_observability_plugin.py b/python/tests/test_observability_plugin.py index 5ad4fb95..ace96b1a 100644 --- a/python/tests/test_observability_plugin.py +++ b/python/tests/test_observability_plugin.py @@ -15,6 +15,7 @@ OBSERVABILITY_PLUGIN_KIND, AtifConfig, AtofConfig, + AtofEndpointConfig, ComponentSpec, HttpStorageConfig, ObservabilityConfig, @@ -96,6 +97,21 @@ def test_s3_storage_config_serializes_credential_fields(self): atif = AtifConfig(enabled=True, storage=[storage]) assert atif.to_dict()["storage"] == [storage.to_dict()] + def test_atof_endpoint_config_serializes_streaming_fields(self): + endpoint = AtofEndpointConfig( + url="http://localhost:8080/events", + transport="http_post", + headers={"X-Test": "yes"}, + timeout_millis=1000, + ) + assert endpoint.to_dict() == { + "url": "http://localhost:8080/events", + "transport": "http_post", + "headers": {"X-Test": "yes"}, + "timeout_millis": 1000, + } + assert AtofConfig(endpoints=[endpoint]).to_dict()["endpoints"] == [endpoint.to_dict()] + def test_http_storage_config_serializes_headers(self): s3 = S3StorageConfig(bucket="archive") http = HttpStorageConfig( diff --git a/python/tests/test_types.py b/python/tests/test_types.py index da838727..078b6299 100644 --- a/python/tests/test_types.py +++ b/python/tests/test_types.py @@ -13,6 +13,7 @@ from nemo_relay import ( AtifExporter, + AtofEndpointConfig, AtofExporter, AtofExporterConfig, AtofExporterMode, @@ -457,15 +458,27 @@ def test_config_defaults_mutation_and_repr(self, tmp_path): assert config.mode == AtofExporterMode.Append assert config.filename.startswith("nemo-relay-events-") assert config.filename.endswith(".jsonl") + assert config.endpoints == [] assert "AtofExporterConfig" in repr(config) config.output_directory = str(tmp_path) config.mode = AtofExporterMode.Overwrite config.filename = "events.jsonl" + endpoint = AtofEndpointConfig( + "http://localhost:8080/events", + transport="http_post", + headers={"X-Test": "yes"}, + timeout_millis=1000, + ) + config.endpoints = [endpoint] assert config.output_directory == str(tmp_path) assert config.mode == AtofExporterMode.Overwrite assert config.filename == "events.jsonl" + assert config.endpoints[0].url == "http://localhost:8080/events" + assert config.endpoints[0].transport == "http_post" + assert config.endpoints[0].headers == {"X-Test": "yes"} + assert config.endpoints[0].timeout_millis == 1000 def test_exporter_lifecycle_writes_raw_jsonl_events(self, tmp_path): config = AtofExporterConfig()