├── .editorconfig
├── .git-blame-ignore-revs
├── .gitignore
├── .gitmodules
├── .lua-format
├── CHANGELOG.md
├── LICENSE
├── NOTICE
├── README.md
├── addon.json
├── config.ld
├── docs
├── index.html
├── ldoc.css
└── topics
│ └── readme.md.html
└── lua
├── autorun
└── errors.lua
└── includes
└── modules
└── sentry.lua
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | indent_size = 4
6 | tab_width = 4
7 | end_of_line = crlf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | ## 2020 format wars
2 | # Parens
3 | dab6154ea8ea5eadc67046ceaaadf75724d0bcf5
4 | # Semicolons
5 | e140c8b7c16631b7b86af15098fdcee44a9eefae
6 | # Luaformat
7 | 6b3fddbc925f787e7bb5931c5420c8560ed067b0
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | lua/autorun/server/testing.lua
2 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "libgmod"]
2 | path = libgmod
3 | url = git@github.com:Lexicality/libgmod.git
4 |
--------------------------------------------------------------------------------
/.lua-format:
--------------------------------------------------------------------------------
1 | column_limit: 80
2 | indent_width: 1
3 | use_tab: true
4 | tab_width: 4
5 | continuation_indent_width: 1
6 | spaces_before_call: 1
7 | keep_simple_control_block_one_line: false
8 | keep_simple_function_one_line: false
9 | align_args: true
10 | break_after_functioncall_lp: true
11 | break_before_functioncall_rp: true
12 | align_parameter: false
13 | chop_down_parameter: true
14 | break_after_functiondef_lp: true
15 | break_before_functiondef_rp: true
16 | align_table_field: true
17 | break_after_table_lb: true
18 | break_before_table_rb: true
19 | chop_down_table: true
20 | chop_down_kv_table: true
21 | table_sep: ","
22 | extra_sep_at_table_end: true
23 | break_after_operator: true
24 | double_quote_to_single_quote: false
25 | single_quote_to_double_quote: true
26 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ### Changed
11 |
12 | - Fix typo breaking `sentry.ExtraContext`
13 | - Fix incorrect date format for `app_start_time`
14 | - Use `rawget` to find the versions of global variables to avoid invoking metamethods
15 | - Fix startup errors crashing the server in the August 2020 update (`200818`)
16 |
17 | ## [0.0.1] - 2018-08-19
18 |
19 | ### Added
20 |
21 | - First release of module
22 |
23 | [Unreleased]: https://github.com/lexicality/gmod-sentry/compare/v0.0.1...HEAD
24 | [0.0.1]: https://github.com/lexicality/gmod-sentry/releases/tag/v0.0.1
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | Garry's Mod Sentry Integration
2 | Copyright 2018 Lex Robinson
3 |
4 | This work includes a UUID generation function that is copyright 2015 The Wiremod Team and used under the terms of the Apache license
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sentry integration for Garry's Mod
2 |
3 | Track errors as they happen on your server, find out which workshop addon is making your players quit and track bugs without having to rely on user reports.
4 |
5 | ## Requirements
6 | - A server host that lets you install binary modules
7 | - [gmsv_luaerror.dll][luaerror] Installed in your server's lua/bin folder
8 | - An account on [sentry.io][sentry] or a [custom sentry installation][custom_sentry]
9 |
10 | ## Setup
11 | 1. [Download][luaerror_dl] and install the correct version of luaerror from your server (eg gmsv_luaerror_linux.dll)
12 | 2. Set up a project in Sentry
13 | 3. [Find your DSN][sentry_dsn]
14 | 4. Upload sentry.lua to `lua/includes/modules` on the server
15 | 5. Create `lua/autorun/server/sentry.lua` on the server with the contents
16 | ```lua
17 | require( "sentry" )
18 | sentry.Setup( "YOUR DSN HERE", { server_name = "SHORT NAME FOR SERVER" } )
19 | ```
20 | 6. Start collecting errors!
21 |
22 | ## Customisation
23 | ### sentry.Setup()
24 | You can pass a number of fields to [`sentry.Setup`](https://lexicality.github.io/gmod-sentry#Setup):
25 | - `server_name`: Tags your server in the sentry UI. If you have more than one server, this is useful for filtering between them. If you don't set it, your server's public hostname will be used.
26 | - `environment`: Used for setting up [Environments][sentry_env] on sentry. Not very useful if you don't run a testing server.
27 | - `release`: Used by the [Releases][sentry_rel] feature in Sentry.
28 | - `tags`: Any additional tags you want every error from this server to be tagged with
29 | - `no_detour`: If you don't want the module to override certain functions (because you've already overriden them) then pass them in here.
30 |
31 | #### Example:
32 | ```lua
33 | sentry.Setup(
34 | "https://key@sentry.io/1337",
35 | {
36 | server_name = "server 7",
37 | environment = "production",
38 | release = "v23",
39 | tags = { foo = "bar" },
40 | no_detour = { "hook.Call" },
41 | }
42 | )
43 | ```
44 |
45 | ### Transactions
46 | By default this module will detour a number of Lua entry points to attempt to instrument as many things with useful transaction names as possible.
47 |
48 | This means your errors will be tagged with things such as `hook/PlayerInitialSpawn/DarkRP_DoorData` or `net/GModSave`, but you may wish to use your own names for functions. You can use [`sentry.ExecuteInTransaction`](https://lexicality.github.io/gmod-sentry#ExecuteInTransaction) to do this.
49 |
50 | #### Example:
51 | ```lua
52 | function DoDatabaseSave( ply )
53 | -- snip
54 | end
55 | hook.Add( "PlayerDisconnected", "Save Player Data", function( ply )
56 | sentry.ExecuteInTransaction( "My Save System", DoDatabaseSave, ply )
57 | end)
58 | ```
59 |
60 | ## Documentation
61 | A generated [LDoc][ldoc] file is available at [https://lexicality.github.io/gmod-sentry](https://lexicality.github.io/gmod-sentry)
62 |
63 |
64 | [luaerror]: https://github.com/danielga/gm_luaerror/
65 | [luaerror_dl]: https://github.com/danielga/gm_luaerror/releases
66 | [sentry]: https://sentry.io/
67 | [sentry_env]: https://docs.sentry.io/learn/environments/
68 | [sentry_rel]: https://docs.sentry.io/learn/releases/
69 | [custom_sentry]: https://docs.sentry.io/server/installation/
70 | [sentry_dsn]: https://docs.sentry.io/quickstart/#about-the-dsn
71 | [ldoc]: https://stevedonovan.github.io/ldoc/
72 |
--------------------------------------------------------------------------------
/addon.json:
--------------------------------------------------------------------------------
1 | {
2 | "title" : "Sentry.io Integration",
3 | "type" : "tool",
4 | "tags" : [],
5 | "ignore" :
6 | [
7 | "LICENSE",
8 | "NOTICE",
9 | ".editorconfig",
10 | "*.md",
11 | ".git*"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/config.ld:
--------------------------------------------------------------------------------
1 | file = "lua/includes/modules/sentry.lua"
2 | title = "sentry.lua Reference"
3 | project = "GMod Sentry"
4 | readme = "README.md"
5 | format = "markdown"
6 | dir = "docs"
7 | boilerplate = true
8 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
Set the current player for this context
137 | Does nothing if no transaction is active
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
Functions
146 |
147 |
148 |
149 |
150 | CaptureException (err, extra)
151 |
152 |
153 | Captures an exception for sentry, using the current stack as the error's stack
154 | Most useful inside an xpcall handler
155 |
156 |
157 |
Parameters:
158 |
159 |
err
160 | The raw Lua error that happened, with or without file details
161 |
162 |
extra
163 | Any other information about the error to upload to Sentry with it
164 |
165 |
166 |
167 |
Returns:
168 |
169 |
170 | The generated error's ID or nil if it was automatically discarded
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 | pcall ([extra], func, ...)
180 |
181 |
182 | Works like normal pcall
183 | but uploads the error to Sentry as well as returning it
184 |
185 |
186 |
Parameters:
187 |
188 |
extra
189 | Other info to send to the server if func errors
190 | (optional)
191 |
192 |
func
193 | The function to pcall
194 |
195 |
...
196 | Arguments to pass to func
197 |
198 |
199 |
200 |
Returns:
201 |
202 |
203 | Its first result is the status code (a boolean), which is true if the call succeeds without errors. In such case, pcall also returns all results from the call, after this first result. In case of any error, pcall returns false plus the error message.
204 |
205 |
206 |
207 |
208 |
209 |
215 | Executes a function in transaction context.
216 | If the function throws an error, the error will be reported to sentry and then will be re-raised.
217 | If you don't want the error re-raised, use sentry.pcall
218 | Both name and txn are optional
219 |
220 |
221 |
Parameters:
222 |
223 |
name
224 | The name of the transaction or nil if not applicable
225 | (optional)
226 |
227 |
txn
228 | The data to attach to the transaction
229 | (optional)
230 |
388 | Checks if Sentry thinks a transaction is active
389 | Ideally true, but could be false if an undetoured entrypoint is used
390 |
391 |
392 |
393 |
442 | Add data to the current transaction's context.
443 | Anything here will override the transaction's starting values
444 | Does nothing if no transaction is active
445 |
446 |
447 |
Parameters:
448 |
449 |
data
450 | Data to add
451 |
452 |
453 |
454 |
455 |
456 |
457 |
Usage:
458 |
459 |
sentry.MergeContext({ culprit = "your mum" })
460 |
461 |
462 |
463 |
464 |
465 | ClearContext ()
466 |
467 |
468 | Remove any extra data from the current transaction.
469 | Does not affect the data the transaction was started with.
470 | Does nothing if no transaction is active
471 |
472 |
473 |
474 |
475 |
476 |
477 |
478 |
479 |
480 |
481 | TagsContext (tags)
482 |
483 |
484 | Merge tags into the current transaction's context
485 | Does nothing if no transaction is active
486 |
487 |
488 |
Parameters:
489 |
490 |
tags
491 | A table of tag names as keys, values as values
492 |
493 |
494 |
495 |
496 |
497 |
498 |
Usage:
499 |
500 |
sentry.TagsContext({ somecondition = "passed" })
501 |
502 |
503 |
504 |
505 |
506 | ExtraContext (tags)
507 |
508 |
509 | Merge the extra field into the current transaction's context
510 | Does nothing if no transaction is active
511 |
512 |
513 |
Parameters:
514 |
515 |
tags
516 | A table of arbitrary data to send to Sentry
517 |
518 |
519 |
520 |
521 |
522 |
523 |
Usage:
524 |
525 |
sentry.ExtraContext({ numplayers = 23 })
526 |
527 |
528 |
529 |
530 |
531 | UserContext (user)
532 |
533 |
534 | Set the current player for this context
535 | Does nothing if no transaction is active
536 |
537 |
538 |
Parameters:
539 |
540 |
user
541 | A player object
542 |
543 |
544 |
545 |
546 |
547 |
548 |
Usage:
549 |
550 |
sentry.UserContext(ply)
551 |
552 |
553 |
554 |
555 |
556 |
557 |
558 |
559 |
560 | generated by LDoc 1.4.6
561 | Last updated 2018-08-19 20:16:56
562 |
Track errors as they happen on your server, find out which workshop addon is making your players quit and track bugs without having to rely on user reports.
1. Download and install the correct version of luaerror from your server (eg gmsvluaerrorlinux.dll)
66 | 2. Set up a project in Sentry
67 | 3. Find your DSN
68 | 4. Upload sentry.lua to lua/includes/modules on the server
69 | 5. Create lua/autorun/server/sentry.lua on the server with the contents
70 | ```lua
71 | require( "sentry" )
72 | sentry.Setup( "YOUR DSN HERE", { server_name = "SHORT NAME FOR SERVER" } )
73 | ```
74 | 6. Start collecting errors!
75 |
76 |
77 |
Customisation
78 |
sentry.Setup()
79 |
You can pass a number of fields to sentry.Setup:
80 | - server_name: Tags your server in the sentry UI. If you have more than one server, this is useful for filtering between them. If you don't set it, your server's public hostname will be used.
81 | - environment: Used for setting up Environments on sentry. Not very useful if you don't run a testing server.
82 | - release: Used by the Releases feature in Sentry.
83 | - tags: Any additional tags you want every error from this server to be tagged with
84 | - no_detour: If you don't want the module to override certain functions (because you've already overriden them) then pass them in here.
By default this module will detour a number of Lua entry points to attempt to instrument as many things with useful transaction names as possible.
104 |
105 |
This means your errors will be tagged with things such as hook/PlayerInitialSpawn/DarkRP_DoorData or net/GModSave, but you may wish to use your own names for functions. You can use sentry.ExecuteInTransaction to do this.
106 |
107 |
Example:
108 |
109 |
110 | function DoDatabaseSave( ply )
111 | -- snip
112 | end
113 | hook.Add( "PlayerDisconnected", "Save Player Data", function( ply )
114 | sentry.ExecuteInTransaction( "My Save System", DoDatabaseSave, ply )
115 | end)
116 |
129 | generated by LDoc 1.4.6
130 | Last updated 2018-08-19 20:16:56
131 |
132 |
133 |
134 |
135 |
--------------------------------------------------------------------------------
/lua/autorun/errors.lua:
--------------------------------------------------------------------------------
1 | local function throws(arg1, arg2)
2 | local foo = 8;
3 | local bar = arg1 .. arg2;
4 | error("oops");
5 | end
6 |
7 | local function level1(arg1)
8 | local two = "beans";
9 | throws(arg1, two);
10 | end
11 |
12 | local function level2(func)
13 | level1("beans");
14 | end
15 |
16 | local function level3()
17 | level2()
18 | end
19 | local function level4()
20 | level3()
21 | end
22 | local function level5()
23 | level4()
24 | end
25 |
26 | if SERVER then
27 | concommand.Add(
28 | "oops", function()
29 | level5()
30 | end
31 | );
32 | else
33 | concommand.Add(
34 | "cl_oops", function()
35 | level5()
36 | end
37 | );
38 | end
39 |
40 | function drspang()
41 | local res, err = xpcall(level5, sentry.CaptureException)
42 | if (not res and err) then
43 | ErrorNoHalt(err);
44 | end
45 | end
46 |
47 | function drspangles()
48 | sentry.pcall(level5)
49 | end
50 |
51 | hook.Add(
52 | "One", "aoeu", function()
53 | error("Oops")
54 | end
55 | );
56 | hook.Add(
57 | "Two", "ueoa", function()
58 | hook.Run("One")
59 | end
60 | )
61 | hook.Add(
62 | "Three", "spang", function()
63 | hook.Run("Two")
64 | end
65 | )
66 | hook.Add(
67 | "Four", "flang", function()
68 | hook.Run("Three")
69 | end
70 | )
71 | concommand.Add(
72 | "hookception", function()
73 | hook.Run("Four")
74 | end
75 | )
76 |
--------------------------------------------------------------------------------
/lua/includes/modules/sentry.lua:
--------------------------------------------------------------------------------
1 | --[[
2 | Garry's Mod Sentry Integration
3 | Copyright 2018 Lex Robinson
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 | ]] --
17 | ---
18 | -- Provides an interface to [Sentry](https://sentry.io) from GLua
19 | --
20 | -- [Github Page](https://github.com/lexicality/gmod-sentry/)
21 | -- @module sentry
22 | -- @author Lex Robinson
23 | -- @copyright 2018 Lex Robinson
24 | require("luaerror")
25 | if not luaerror then
26 | error("Please make sure you've installed gm_luaerror correctly")
27 | end
28 |
29 | local GetHostName = GetHostName
30 | local HTTP = HTTP
31 | local IsValid = IsValid
32 | local ServerLog = ServerLog
33 | local SysTime = SysTime
34 | local bit = bit
35 | local error = error
36 | local hook = hook
37 | local ipairs = ipairs
38 | local isstring = isstring
39 | local luaerror = luaerror
40 | local math = math
41 | local net = net
42 | local os = os
43 | local pairs = pairs
44 | local rawget = rawget
45 | local setmetatable = setmetatable
46 | local string = string
47 | local system = system
48 | local table = table
49 | local tonumber = tonumber
50 | local tostring = tostring
51 | local type = type
52 | local unpack = unpack
53 | local util = util
54 | local xpcall = xpcall
55 | -- debugging
56 | local debug = debug
57 | local print = print
58 | local PrintTable = PrintTable
59 |
60 | local g = _G
61 | module("sentry")
62 |
63 | --
64 | -- Global Config
65 | --
66 | local config = {
67 | endpoint = nil,
68 | privatekey = nil,
69 | publickey = nil,
70 | projectID = nil,
71 | tags = {},
72 | release = nil,
73 | environment = nil,
74 | server_name = nil,
75 | no_detour = {},
76 | }
77 |
78 | --
79 | -- Versioning
80 | --
81 | SDK_VALUE = {name = "GMSentry", version = "0.0.1"}
82 | -- LuaJIT Style
83 | Version = string.format("%s %s", SDK_VALUE.name, SDK_VALUE.version)
84 | VersionNum = string.format(
85 | "%02d%02d%02d", string.match(SDK_VALUE.version, "(%d+).(%d+).(%d+)")
86 | )
87 |
88 | --
89 | -- Utility Functions
90 | --
91 |
92 | --
93 | -- Generates a v4 UUID without dashes
94 | -- Copied from wirelib almost verbatim
95 | -- @return a UUID in hexadecimal string format.
96 | function UUID4()
97 | -- It would be easier to generate this by word rather than by byte, but
98 | -- MSVC's RAND_MAX = 0x7FFF, which means math.random(0, 0xFFFF) won't
99 | -- return all possible values.
100 | local bytes = {}
101 | for i = 1, 16 do
102 | bytes[i] = math.random(0, 0xFF)
103 | end
104 | bytes[7] = bit.bor(0x40, bit.band(bytes[7], 0x0F))
105 | bytes[9] = bit.bor(0x80, bit.band(bytes[7], 0x3F))
106 | return string.format(
107 | "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x",
108 | unpack(bytes)
109 | )
110 | end
111 |
112 | ---
113 | -- Generates an ISO 8601/RFC 6350 formatted date
114 | -- @within util
115 | -- @param time The unix timestamp to generate the date from
116 | -- @return The date string
117 | function ISODate(time)
118 | return os.date("!%Y-%m-%dT%H:%M:%S", time)
119 | end
120 |
121 | ---
122 | -- Generates a pretty printed name of the current operating sytem
123 | -- @within util
124 | -- @return "Windows", "macOS", "Linux" or nil.
125 | function GetOSName()
126 | if system.IsWindows() then
127 | return "Windows"
128 | elseif system.IsOSX() then
129 | return "macOS"
130 | elseif system.IsLinux() then
131 | return "Linux"
132 | end
133 | return nil
134 | end
135 |
136 | ---
137 | -- Writes a logline to the Server log, using string.format
138 | -- @within util
139 | -- @param message Logline to write
140 | -- @param ... Values to format into it
141 | local function WriteLog(message, ...)
142 | ServerLog(string.format("Sentry: %s\n", message:format(...)))
143 | end
144 |
145 | --
146 | -- Module Detection
147 | --
148 |
149 | ---
150 | -- All the modules Sentry has detected.
151 | -- Anything added to this will also be sent to Sentry
152 | -- @usage sentry.DetectedModules["foo"] = "7.2"
153 | DetectedModules = {}
154 |
155 | ---
156 | -- More complex ways of detecting a module's version
157 | -- @field _
158 | -- @usage sentry.DetectionFuncs["global name"] = function(global_value) return "version", "optional override name" end
159 | DetectionFuncs = {
160 | mysqloo = function(mysqloo)
161 | return string.format("%d.%d", mysqloo.VERSION, mysqloo.MINOR_VERSION or 0)
162 | end,
163 | CPPI = function(CPPI)
164 | local name = CPPI:GetName()
165 | local version = CPPI:GetVersion()
166 | if version == CPPI.CPPI_NOT_IMPLEMENTED then
167 | -- ???
168 | return nil
169 | end
170 | return version, name
171 | end,
172 | ulx = function(ulx)
173 | -- Why is this better than ulx.version
174 | local ULib = g["ULib"]
175 | if ULib and ULib.pluginVersionStr then
176 | return ULib.pluginVersionStr("ULX")
177 | end
178 | return ulx.version or ulx.VERSION
179 | end,
180 | ULib = function(ULib)
181 | if ULib.pluginVersionStr then
182 | return ULib.pluginVersionStr("ULib")
183 | end
184 | return ULib.version or ULib.VERSION
185 | end,
186 | GM = function(GM)
187 | if not GM.Version then
188 | return nil
189 | end
190 | return GM.Version, string.format("Gamemode: %s", GM.Name)
191 | end,
192 | }
193 | DetectionFuncs["GAMEMODE"] = DetectionFuncs["GM"]
194 |
195 | local LUAJIT_VERSION = "(.+) (%d+%.%d+%.%d+)"
196 | ---
197 | -- Loops through _G and tries to find anything with some variant of a VERSION field.
198 | local function detectModules()
199 | local VERSION = g["VERSION"]
200 |
201 | for name, value in pairs(g) do
202 | local func = DetectionFuncs[name]
203 | if func then
204 | -- Overrides
205 | local _, version, override = xpcall(func, CaptureException, value)
206 |
207 | if version then
208 | DetectedModules[override or name] = tostring(version)
209 | end
210 | elseif type(value) == "table" and name ~= "sentry" then
211 | -- Magic guessing game
212 | local version = rawget(value, "version") or rawget(value, "Version") or
213 | rawget(value, "VERSION")
214 |
215 | if version and version ~= VERSION and type(version) ~= "function" then
216 | version = tostring(version)
217 |
218 | -- Try and deal with LuaJIT style version strings
219 | local override, realversion = string.match(version, LUAJIT_VERSION)
220 | if override then
221 | version = realversion
222 | end
223 |
224 | DetectedModules[override or name] = version
225 | end
226 | end
227 | end
228 | end
229 |
230 | --
231 | -- Rate Limiting
232 | --
233 | local retryAfter = nil
234 | local skipNext = nil
235 | ---
236 | -- Checks if an error should be reported to sentry
237 | -- @param err The FULL error message including embedded where line
238 | -- @return true to report, false to discard
239 | local function shouldReport(err)
240 | if not config.endpoint then
241 | return false
242 | elseif retryAfter ~= nil then
243 | local now = SysTime()
244 | if retryAfter > now then
245 | return false
246 | end
247 |
248 | retryAfter = nil
249 | elseif string.find(err, "ISteamHTTP isn't available") then
250 | return false
251 | end
252 |
253 | if skipNext == err then
254 | skipNext = nil
255 | return false
256 | end
257 | skipNext = nil
258 |
259 | return true
260 | end
261 |
262 | ---
263 | -- Disables sending messages to sentry for a period
264 | -- @param backoff how many seconds to wait
265 | local function doBackoff(backoff)
266 | local expires = SysTime() + backoff
267 | if retryAfter == nil or retryAfter < expires then
268 | WriteLog("Rate Limiting for %d seconds!", backoff)
269 | retryAfter = expires
270 | end
271 | end
272 |
273 | ---
274 | -- Detects if the server is telling us to back off and by how much
275 | -- @param code HTTP status code in number form
276 | -- @param headers Table of HTTP response headers
277 | -- @return true if the server is unhappy with us
278 | local function detectRateLimiting(code, headers)
279 | local backoff = tonumber(headers["Retry-After"])
280 | -- Shouldn't happen, but might
281 | if code == 429 and not backoff then
282 | backoff = 20
283 | end
284 |
285 | if not backoff then
286 | return false
287 | end
288 |
289 | doBackoff(backoff)
290 |
291 | return true
292 | end
293 |
294 | --
295 | -- File Identification
296 | --
297 | local ADDON_FILE_PATTERN = "^@addons/([^/]+)/lua/(.*).lua$"
298 | local GAMEMODE_FILE_PATTERN = "^@gamemodes/([^/]+)/(.*).lua$"
299 | local ADDON_GAMEMODE_FILE_PATTERN = "^@addons/[^/]+/gamemodes/([^/]+)/(.*).lua$"
300 | local OTHER_FILE_PATTERN = "^@lua/(.*).lua$"
301 | ---
302 | -- Generates a "module" name from a lua path
303 | -- @param path A full stacktrace lua path like "@addons/foo/lua/bar/baz.lua"
304 | -- @return A pretty name like "foo.bar.baz" or "unknown" if the path makes no sense
305 | local function modulify(path)
306 | if path == "=[C]" then
307 | return "engine"
308 | elseif path == "@lua_run" then
309 | return "lua_run"
310 | end
311 |
312 | local addon, rest = string.match(path, ADDON_FILE_PATTERN)
313 | if addon then
314 | return addon .. "." .. rest:gsub("/", ".")
315 | end
316 |
317 | local gamemode, rest = string.match(path, GAMEMODE_FILE_PATTERN)
318 | if not gamemode then
319 | gamemode, rest = string.match(path, ADDON_GAMEMODE_FILE_PATTERN)
320 | end
321 | if gamemode then
322 | return gamemode .. "." .. rest:gsub("/", ".")
323 | end
324 |
325 | local rest = string.match(path, OTHER_FILE_PATTERN)
326 | if not rest then
327 | return "unknown"
328 | end
329 |
330 | local name, id = luaerror.FindWorkshopAddonFileOwner(path:sub(2))
331 | if not name then
332 | return "unknown." .. rest:gsub("/", ".")
333 | end
334 |
335 | -- Asciify name
336 | name = name:lower():gsub("[^%w]+", "-"):gsub("%-+", "-"):gsub(
337 | "^%-*(.-)%-*$", "%1"
338 | )
339 | -- Lua doesn't do unicode, so if the workshop name is in cyrilic or something, it'll now be empty
340 | if name:len() < 3 then
341 | -- Heck
342 | name = "workshop-" .. id
343 | end
344 |
345 | return name .. "." .. rest:gsub("/", ".")
346 | end
347 |
348 | --
349 | -- Stack Reverse Engineering
350 | --
351 |
352 | ---
353 | -- Turns a lua stacktrace into a Sentry stacktrace
354 | -- @param stack Lua stacktrace in debug.getinfo style
355 | -- @return A reversed stacktrace with different field names
356 | local function sentrifyStack(stack)
357 | -- Sentry likes stacks in the oposite order to lua
358 | stack = table.Reverse(stack)
359 |
360 | -- The first entry from LuaError is sometimes useless
361 | if stack[#stack]["source"] == "=[C]" and stack[#stack]["name"] == "" then
362 | table.remove(stack)
363 | end
364 | -- If someone has called `error`, remove it from the stack trace
365 | if stack[#stack]["source"] == "=[C]" and stack[#stack]["name"] == "error" then
366 | table.remove(stack)
367 | end
368 |
369 | local ret = {}
370 | for i, frame in ipairs(stack) do
371 | ret[i] = {
372 | filename = frame["source"]:sub(2),
373 | ["function"] = frame["name"] or "",
374 | module = modulify(frame["source"]),
375 | lineno = frame["currentline"],
376 | }
377 | end
378 | return {frames = ret}
379 | end
380 |
381 | ---
382 | -- Extract the current stacktrace
383 | -- @return A Lua stacktrace
384 | local function getStack()
385 | local level = 3 -- 1 = this, 2 = CaptureException
386 |
387 | local stack = {}
388 | while true do
389 | local info = debug.getinfo(level, "Sln")
390 | if not info then
391 | break
392 | end
393 |
394 | stack[level - 2] = info
395 |
396 | level = level + 1
397 | end
398 |
399 | return stack
400 | end
401 |
402 | ---
403 | -- Removes file info from lua errors by matching it with the stacktrace
404 | -- If the file does not occur in the stacktrace, it does not strip it.
405 | -- @param err an error like "lua/foo.lua:5: oops"
406 | -- @param stack The error's stacktrace
407 | -- @return Hopefully a nice error like "oops" or the full error if not
408 | local function stripFileData(err, stack)
409 | local match, file, line = string.match(err, "^((.+):(%d+): ).+$")
410 | if not match then
411 | return err
412 | end
413 |
414 | for _, frame in pairs(stack) do
415 | if frame["source"] == "@" .. file and tostring(frame["currentline"]) ==
416 | tostring(line) then
417 | err = err:sub(#match + 1)
418 | break
419 | end
420 | end
421 |
422 | return err
423 | end
424 |
425 | local ADDON_BLAME_PATTERN = "^addons/([^/]+)/"
426 | local GAMEMODE_BLAME_PATTERN = "^gamemodes/([^/]+)/"
427 | ---
428 | -- Creates tags from the stack trace to help point the finger at the error's source
429 | -- @param stack The full Lua stacktrace
430 | -- @return An array of tags in sentry format
431 | local function calculateBlame(stack)
432 | for _, frame in pairs(stack) do
433 | if frame["source"] ~= "=[C]" then
434 | local source = frame["source"]:sub(2)
435 |
436 | local wsname, wsid = luaerror.FindWorkshopAddonFileOwner(source)
437 | if wsname then
438 | return {{"addon", "workshop-" .. wsid}, {"addon-name", wsname}}
439 | end
440 |
441 | local addon = string.match(source, ADDON_BLAME_PATTERN)
442 | if addon then
443 | return {{"addon", addon}}
444 | end
445 |
446 | local gamemode = string.match(source, GAMEMODE_BLAME_PATTERN)
447 | if gamemode then
448 | return {{"gamemode", gamemode}}
449 | end
450 | end
451 | end
452 |
453 | return {}
454 | end
455 |
456 | --
457 | -- Transaction Management
458 | --
459 | local transactionStack = {}
460 | ---
461 | -- Checks if Sentry thinks a transaction is active
462 | -- Ideally true, but could be false if an undetoured entrypoint is used
463 | -- @within Transactions
464 | -- @return true or false
465 | function IsInTransaction()
466 | return #transactionStack > 0
467 | end
468 |
469 | ---
470 | -- Adds a transaction to the stack
471 | -- @param data The transaction's state
472 | -- @return The transaction's ID for popping
473 | local function pushTransaction(data)
474 | local txn = {data = data, ctx = {}, id = UUID4()}
475 |
476 | transactionStack[#transactionStack + 1] = txn
477 |
478 | return txn.id
479 | end
480 |
481 | ---
482 | -- Pops a transaction from the stack
483 | -- If the transaction is not at the head of the stack, pops everything above it too.
484 | -- @param id The transaction's ID
485 | -- @return The transaction's state
486 | local function popTransaction(id)
487 | for i, txn in pairs(transactionStack) do
488 | if txn.id == id then
489 | -- Nuke everything above this tranasction in the stack
490 | while transactionStack[i] do
491 | table.remove(transactionStack, i)
492 | end
493 |
494 | -- If this is the last transaction, discard any pending skips
495 | -- "Bug": If you start a transaction from within builtin xpcall inside an
496 | -- active transaction, that transaction fails and you immediately call that
497 | -- transaction again and it fails again, the second error won't be reported
498 | -- to sentry.
499 | -- If you run into this bug, reevaulate your life choices
500 | if not IsInTransaction() then
501 | skipNext = nil
502 | end
503 |
504 | return txn.data
505 | end
506 | end
507 |
508 | error("Unknown Transaction '" .. tostring(id) .. "'!")
509 | end
510 |
511 | ---
512 | -- Merges all active transactions oldest to newest to get a composite block of data
513 | -- Also merges any context overrides from each transaction at the time
514 | -- @return A nice block of transaction context, or empty table if no transactions are active
515 | local function getTransactionData()
516 | local res = {}
517 |
518 | for _, txn in ipairs(transactionStack) do
519 | table.Merge(res, txn.data)
520 | table.Merge(res, txn.ctx)
521 | end
522 |
523 | return res
524 | end
525 |
526 | ---
527 | -- Gets the top transaction on the stack
528 | -- @return The full transaction meta object, or nil if there is no transaction active
529 | local function getCurrentTransaction()
530 | return transactionStack[#transactionStack]
531 | end
532 |
533 | --
534 | -- Context Management
535 | --
536 | ---
537 | -- Converts user context to player data
538 | -- Requires there to be a "user" field in extra and requires it to be a valid player object
539 | -- @param extra The fully merged frame context
540 | -- @return A sentry formatted userdata or nil
541 | local function getUserContext(extra)
542 | local ply = extra["user"]
543 | if not IsValid(ply) then
544 | return nil
545 | end
546 |
547 | return {
548 | id = ply:SteamID(),
549 | username = ply:Nick(),
550 | ip = ply:IPAddress(),
551 | steamid64 = ply:SteamID64(),
552 | }
553 | end
554 |
555 | ---
556 | -- Converts stack context into Sentry context objects
557 | -- @param extra The fully merged frame context
558 | -- @return A sentry formatted object to go into the "contexts" field
559 | local function getContexts(extra)
560 | return {
561 | os = {name = GetOSName()},
562 | runtime = {name = "Garry's Mod", version = g["VERSIONSTR"]},
563 | app = {app_start_time = ISODate(math.floor(os.time() - SysTime()))},
564 | user = getUserContext(extra),
565 | }
566 | end
567 |
568 | ---
569 | -- Generate a set of sentry formatted tags from the inital setup & passed context
570 | -- @param extra The fully merged frame context
571 | -- @return A sentry formatted object to go into the "tags" field
572 | local function getTags(extra)
573 | local tags = {}
574 |
575 | for name, value in pairs(config.tags) do
576 | table.insert(tags, {name, value})
577 | end
578 |
579 | -- Sentry would like extra tag values to suppliment the SDK tags when you send
580 | -- them, but will still only allow one of each tag to exist.
581 | -- I'm not entirely sure why, but best to do what the server asks for.
582 | if extra["tags"] then
583 | for name, value in pairs(extra.tags) do
584 | table.insert(tags, {name, value})
585 | end
586 | end
587 |
588 | return tags
589 | end
590 |
591 | --
592 | -- Payload
593 | --
594 | ---
595 | -- Build a sentry JSON payload from an error
596 | -- This will merge in transaction data & SDK preset values
597 | -- @param err The normalised error string (no filepath included)
598 | -- @param stacktrace The Lua stacktrace for the error
599 | -- @param extra Any additional context for the error
600 | -- @return A full sentry object ready to be JSON'd and uplodaded
601 | local function buildPayload(err, stacktrace, extra)
602 | local txn = getTransactionData()
603 | table.Merge(txn, extra)
604 |
605 | local tags = getTags(txn)
606 | table.Add(tags, calculateBlame(stacktrace))
607 |
608 | return {
609 | event_id = UUID4(),
610 | timestamp = ISODate(os.time()),
611 | logger = "sentry",
612 | platform = "other",
613 | sdk = SDK_VALUE,
614 | exception = {
615 | {type = "error", value = err, stacktrace = sentrifyStack(stacktrace)},
616 | },
617 | modules = DetectedModules,
618 | contexts = getContexts(txn),
619 | tags = tags,
620 | environment = config["environment"],
621 | release = config["release"],
622 | server_name = config["server_name"],
623 | level = txn["level"],
624 | extra = txn["extra"],
625 | culprit = txn["culprit"],
626 | }
627 | end
628 |
629 | --
630 | -- Actual HTTP Integration
631 | --
632 | local SENTRY_HEADER_FORMAT = ("Sentry sentry_version=7, " ..
633 | "sentry_client=%s/%s, " .. "sentry_timestamp=%d, " ..
634 | "sentry_key=%s")
635 | ---
636 | -- Build the sentry security header
637 | -- @return A string to go in the X-Sentry-Auth header
638 | local function sentryAuthHeader()
639 | local header = SENTRY_HEADER_FORMAT:format(
640 | SDK_VALUE.name, SDK_VALUE.version, os.time(), config.publickey,
641 | config.privatekey
642 | )
643 | -- Sentry <9 needs a secret key
644 | if config.privatekey then
645 | header = header .. (", sentry_secret=%s"):format(config.privatekey)
646 | end
647 | return header
648 | end
649 |
650 | ---
651 | -- Asynchronously upload a payload to the Sentry servers.
652 | -- Returns immediately regardless of success.
653 | -- @param payload a Sentry formatted payload table
654 | local function SendToServer(payload)
655 | HTTP(
656 | {
657 | url = config.endpoint,
658 | method = "POST",
659 | body = util.TableToJSON(payload),
660 | type = "application/json; charset=utf-8",
661 | headers = {["X-Sentry-Auth"] = sentryAuthHeader()},
662 | success = function(code, body, headers)
663 | local result = util.JSONToTable(body) or {}
664 |
665 | if detectRateLimiting(code, headers) then
666 | return
667 | elseif code ~= 200 then
668 | local error = headers["X-Sentry-Error"] or result["error"]
669 |
670 | if code >= 500 then
671 | WriteLog("Server is offline (%s), trying later", error or code)
672 | doBackoff(2)
673 | return
674 | elseif code == 401 then
675 | WriteLog("Access denied - shutting down: %s", error or body)
676 | -- If sentry tells us to go away, go away properly
677 | config.endpoint = nil
678 | return
679 | else
680 | WriteLog("Got HTTP %d from the server: %s", code, error or body)
681 | return
682 | end
683 | end
684 |
685 | -- Debugging
686 | print("Success! Event stored with ID " .. (result["id"] or "?"))
687 | end,
688 | failed = function(reason)
689 | -- This is effectively useless
690 | WriteLog("HTTP request failed: %s", reason)
691 | end,
692 | }
693 | )
694 | end
695 |
696 | --
697 | -- Reporting Functions
698 | --
699 | ---
700 | -- Process & upload a normalised error.
701 | -- @param err The normalised error string (no filepath included)
702 | -- @param stack The Lua stacktrace for the error
703 | -- @param extra Any additional context for the error
704 | -- @return The generated event ID
705 | local function proccessException(err, stack, extra)
706 | if not extra then
707 | extra = {}
708 | end
709 |
710 | local payload = buildPayload(err, stack, extra)
711 |
712 | SendToServer(payload)
713 |
714 | return payload.event_id
715 | end
716 |
717 | ---
718 | -- The gm_luaerror hook at the heart of this module
719 | -- @param is_runtime If this error was a compile error or a runtime error. Largely irrelevent.
720 | -- @param rawErr The full error that gets printed in console.
721 | -- @param file The filename extracted from rawErr
722 | -- @param lineno The line number extracte from rawErr
723 | -- @param err The error string extracted from rawErr
724 | -- @param stack The captured stack trace for the error. May be empty
725 | -- @return Nothing or you'll break everything
726 | local function OnLuaError(is_runtime, rawErr, file, lineno, err, stack)
727 | if not shouldReport(rawErr) then
728 | return
729 | end
730 |
731 | if #stack == 0 then
732 | stack[1] = {
733 | name = is_runtime and "" or "",
734 | source = "@" .. file,
735 | currentline = lineno,
736 | }
737 | end
738 |
739 | proccessException(err, stack)
740 | end
741 |
742 | ---
743 | -- Captures an exception for sentry, using the current stack as the error's stack
744 | -- Most useful inside an xpcall handler
745 | -- @param err The raw Lua error that happened, with or without file details
746 | -- @param extra Any other information about the error to upload to Sentry with it
747 | -- @return The generated error's ID or nil if it was automatically discarded
748 | function CaptureException(err, extra)
749 | if not shouldReport(err) then
750 | return nil
751 | end
752 |
753 | local stack = getStack()
754 |
755 | err = stripFileData(err, stack)
756 |
757 | return proccessException(err, stack, extra)
758 | end
759 |
760 | ---
761 | -- The callback for xpcall to upload errors to sentry
762 | -- @param err Captured error
763 | -- @return err
764 | local function xpcallCB(err)
765 | if not shouldReport(err) then
766 | return err
767 | end
768 |
769 | local stack = getStack()
770 |
771 | local msg = stripFileData(err, stack)
772 |
773 | proccessException(msg, stack)
774 |
775 | -- Return the unmodified error
776 | return err
777 | end
778 |
779 | ---
780 | -- Works like [normal pcall](https://www.lua.org/manual/5.1/manual.html#pdf-pcall)
781 | -- but uploads the error to Sentry as well as returning it
782 | -- @param[opt] extra Other info to send to the server if func errors
783 | -- @param func The function to pcall
784 | -- @param ... Arguments to pass to func
785 | -- @return Its first result is the status code (a boolean), which is true if the call succeeds without errors. In such case, pcall also returns all results from the call, after this first result. In case of any error, pcall returns false plus the error message.
786 | function pcall(func, ...)
787 | local args = {...}
788 | local extra = {}
789 |
790 | -- If the first argument is a table, it's configuring the exception handler
791 | if type(func) == "table" then
792 | extra = func
793 | func = table.remove(args, 1)
794 | end
795 |
796 | local id = pushTransaction(extra)
797 | local res = {xpcall(func, xpcallCB, unpack(args))}
798 | popTransaction(id)
799 |
800 | return unpack(res)
801 | end
802 |
803 | --
804 | -- Transaction Management
805 | --
806 | ---
807 | -- Skip the next message if it matches this message
808 | -- @param msg The full raw lua error including file/line info
809 | function SkipNext(msg)
810 | skipNext = msg
811 | end
812 |
813 | ---
814 | -- [INTERNAL] Executes a function in transaction context
815 | -- @within Transactions
816 | -- @param name The name of the transaction or nil if not applicable
817 | -- @param txn The data to attach to the transaction
818 | -- @param func The function to execute
819 | -- @param ... Arguments to pass to the function
820 | -- @return Whatever func returns
821 | function ExecuteTransaction(name, txn, func, ...)
822 | if name then
823 | txn["culprit"] = name
824 | end
825 |
826 | local noXPCall = IsInTransaction()
827 |
828 | local id = pushTransaction(txn)
829 | local res
830 |
831 | -- If we're already inside a transaction, we don't need to xpcall because the
832 | -- error will bubble all the way up to the root txn
833 | if noXPCall then
834 | res = {true, func(...)}
835 | else
836 | res = {xpcall(func, xpcallCB, ...)}
837 | end
838 |
839 | popTransaction(id)
840 |
841 | local success = table.remove(res, 1)
842 | if not success then
843 | local err = res[1]
844 | SkipNext(err)
845 | -- Boom
846 | error(err, 0)
847 | end
848 |
849 | return unpack(res)
850 | end
851 |
852 | ---
853 | -- Executes a function in transaction context.
854 | -- If the function throws an error, the error will be reported to sentry and then will be re-raised.
855 | -- If you don't want the error re-raised, use sentry.pcall
856 | -- Both name and txn are optional
857 | -- @usage sentry.ExecuteInTransaction("My Thing", mything)
858 | -- @usage sentry.ExecuteInTransaction({ tags = { mything = "awesome"} }, mything)
859 | -- @param[opt] name The name of the transaction or nil if not applicable
860 | -- @param[opt] txn The data to attach to the transaction
861 | -- @param func The function to execute
862 | -- @param ... Arguments to pass to the function
863 | -- @return Whatever func returns
864 | function ExecuteInTransaction(...)
865 | -- vulgar hellcode
866 | local a, b = ...
867 | a, b = type(a), type(b)
868 |
869 | if a == "string" or a == "nil" then
870 | if b == "table" then
871 | return ExecuteTransaction(...)
872 | else
873 | return ExecuteTransaction(..., {}, select(2, ...))
874 | end
875 | elseif a == "table" then
876 | return ExecuteTransaction(nil, ...)
877 | else
878 | return ExecuteTransaction(nil, {}, ...)
879 | end
880 | end
881 |
882 | ---
883 | -- Add data to the current transaction's context.
884 | -- Anything here will override the transaction's starting values
885 | -- Does nothing if no transaction is active
886 | -- @within Transactions
887 | -- @usage sentry.MergeContext({ culprit = "your mum" })
888 | -- @param data Data to add
889 | function MergeContext(data)
890 | local txn = getCurrentTransaction()
891 | -- This might be suprising behaviour, but I don't have any better ideas
892 | if not txn then
893 | return
894 | end
895 |
896 | table.Merge(txn.ctx, data)
897 | end
898 |
899 | ---
900 | -- Remove any extra data from the current transaction.
901 | -- Does not affect the data the transaction was started with.
902 | -- Does nothing if no transaction is active
903 | -- @within Transactions
904 | function ClearContext()
905 | local txn = getCurrentTransaction()
906 | -- This might be suprising behaviour, but I don't have any better ideas
907 | if not txn then
908 | return
909 | end
910 |
911 | txn.ctx = {}
912 | end
913 |
914 | ---
915 | -- Merge tags into the current transaction's context
916 | -- Does nothing if no transaction is active
917 | -- @within Transactions
918 | -- @usage sentry.TagsContext({ somecondition = "passed" })
919 | -- @param tags A table of tag names as keys, values as values
920 | function TagsContext(tags)
921 | MergeContext({tags = tags})
922 | end
923 |
924 | ---
925 | -- Merge the extra field into the current transaction's context
926 | -- Does nothing if no transaction is active
927 | -- @within Transactions
928 | -- @usage sentry.ExtraContext({ numplayers = 23 })
929 | -- @param tags A table of arbitrary data to send to Sentry
930 | function ExtraContext(extra)
931 | MergeContext({extra = extra})
932 | end
933 |
934 | ---
935 | -- Set the current player for this context
936 | -- Does nothing if no transaction is active
937 | -- @within Transactions
938 | -- @usage sentry.UserContext(ply)
939 | -- @param user A player object
940 | function UserContext(user)
941 | MergeContext({user = user})
942 | end
943 |
944 | --
945 | -- Detours
946 | --
947 | local detourMT = {}
948 | detourMT.__index = detourMT
949 | function detourMT:__call(...)
950 | return self.override(self, ...)
951 | end
952 |
953 | function detourMT:_get(extra)
954 | -- I can't think of a sane way of doing this
955 | local p = self.path
956 | if #p == 1 then
957 | return g[p[1] .. extra]
958 | elseif #p == 2 then
959 | return g[p[1]][p[2] .. extra]
960 | else
961 | error("Not implemented")
962 | end
963 | end
964 |
965 | function detourMT:_set(value, extra)
966 | extra = extra or ""
967 | local p = self.path
968 | if #p == 1 then
969 | g[p[1] .. extra] = value
970 | elseif #p == 2 then
971 | g[p[1]][p[2] .. extra] = value
972 | else
973 | error("Not implemented")
974 | end
975 | end
976 |
977 | function detourMT:_reset_existing_detour()
978 | local detour = self:_get("_DT")
979 | if not detour then
980 | return false
981 | end
982 |
983 | detour:Reset()
984 | return true
985 | end
986 |
987 | function detourMT:_get_valid()
988 | if self:_reset_existing_detour() then
989 | return self:_get_valid()
990 | end
991 | local func = self:_get("")
992 |
993 | if type(func) ~= "function" then
994 | return false
995 | end
996 |
997 | local info = debug.getinfo(func, "S")
998 | if info["source"] ~= "@" .. self.module then
999 | return false
1000 | end
1001 |
1002 | return func
1003 | end
1004 |
1005 | function detourMT:Detour()
1006 | local func = self:_get_valid()
1007 | if not func then
1008 | error("Can't detour!")
1009 | end
1010 | self.original = func
1011 | self:_set(self, "_DT")
1012 | -- Engine functions won't talk to magical tables with the __call metafield. :(
1013 | self:_set(
1014 | function(...)
1015 | return self(...)
1016 | end
1017 | )
1018 | end
1019 |
1020 | function detourMT:Reset()
1021 | self:_set(self.original)
1022 | end
1023 |
1024 | function detourMT:Validate(module)
1025 | return self:_get_valid() ~= false
1026 | end
1027 |
1028 | ---
1029 | -- Replaces a function with a custom one.
1030 | -- Does nothing if something else has already overriden the function.
1031 | -- @param func The new function to use
1032 | -- @param target The target to override (eg "hook.Call")
1033 | -- @param expectedModule Where the target is supposed to be (eg "lua/includes/modules/hook.lua")
1034 | -- @return The detour object if the target is acceptable, false if it's not.
1035 | local function createDetour(func, target, expectedModule)
1036 | local detour = {
1037 | override = func,
1038 | path = string.Split(target, "."),
1039 | module = expectedModule,
1040 | }
1041 | setmetatable(detour, detourMT)
1042 |
1043 | if not detour:Validate() then
1044 | return nil
1045 | end
1046 |
1047 | return detour
1048 | end
1049 |
1050 | local function concommandRun(detour, ply, command, ...)
1051 | local cmd = command:lower()
1052 | ExecuteTransaction(
1053 | "cmd/" .. cmd, {tags = {concommand = cmd}, user = ply}, detour.original, ply,
1054 | command, ...
1055 | )
1056 | end
1057 |
1058 | local function netIncoming(detour, len, ply)
1059 | local id = net.ReadHeader()
1060 | local name = util.NetworkIDToString(id)
1061 | if not name then
1062 | CaptureException(
1063 | string.format("Unknown network message with ID %d", id),
1064 | {user = ply, culprit = "net/" .. tostring(id)}
1065 | )
1066 | return
1067 | end
1068 |
1069 | local func = net.Receivers[name:lower()]
1070 | if not func then
1071 | CaptureException(
1072 | string.format("Unknown network message with name %s", name),
1073 | {user = ply, tags = {net_message = name}, culprit = "net/" .. name}
1074 | )
1075 | return
1076 | end
1077 |
1078 | -- len includes the 16 bit int which told us the message name
1079 | len = len - 16
1080 |
1081 | ExecuteTransaction(
1082 | "net/" .. name, {user = ply, tags = {net_message = name}}, func, len, ply
1083 | )
1084 | end
1085 |
1086 | local HOOK_TXN_FORMAT = "hook/%s/%s"
1087 | local function actualHookCall(name, gm, ...)
1088 | -- Heuristics: Pretty much any hook that operates on a player has the player as the first argument
1089 | local ply = ...
1090 | if not (type(ply) == "Player" and IsValid(ply)) then
1091 | ply = nil
1092 | end
1093 |
1094 | local ctx = {user = ply}
1095 |
1096 | local hooks = hook.GetTable()[name]
1097 | if hooks then
1098 | local a, b, c, d, e, f
1099 | for hookname, func in pairs(hooks) do
1100 | if isstring(hookname) then
1101 | a, b, c, d, e, f = ExecuteTransaction(
1102 | string.format(HOOK_TXN_FORMAT, name, hookname), ctx, func, ...
1103 | )
1104 | elseif IsValid(hookname) then
1105 | -- This won't be a great name, but it's the best we can do
1106 | a, b, c, d, e, f = ExecuteTransaction(
1107 | string.format(HOOK_TXN_FORMAT, name, tostring(hookname)), ctx, func,
1108 | hookname, ...
1109 | )
1110 | else
1111 | hooks[hookname] = nil
1112 | end
1113 |
1114 | if a ~= nil then
1115 | return a, b, c, d, e, f
1116 | end
1117 | end
1118 | end
1119 |
1120 | if gm and gm[name] then
1121 | return ExecuteTransaction(
1122 | string.format(HOOK_TXN_FORMAT, "GM", name), ctx, gm[name], gm, ...
1123 | )
1124 | end
1125 | end
1126 |
1127 | local function ulxHookCall(name, gm, ...)
1128 | -- Heuristics: Pretty much any hook that operates on a player has the player as the first argument
1129 | local ply = ...
1130 | if not (type(ply) == "Player" and IsValid(ply)) then
1131 | ply = nil
1132 | end
1133 |
1134 | local ctx = {user = ply}
1135 |
1136 | local hooks = hook.GetULibTable()[name]
1137 | if hooks then
1138 | local a, b, c, d, e, f, func
1139 | for i = -2, 2 do
1140 | for hookname, t in pairs(hooks[i]) do
1141 | func = t.fn
1142 | if t.isstring then
1143 | a, b, c, d, e, f = ExecuteTransaction(
1144 | string.format(HOOK_TXN_FORMAT, name, hookname), ctx, func, ...
1145 | )
1146 | elseif IsValid(hookname) then
1147 | -- This won't be a great name, but it's the best we can do
1148 | a, b, c, d, e, f = ExecuteTransaction(
1149 | string.format(HOOK_TXN_FORMAT, name, tostring(hookname)), ctx, func,
1150 | hookname, ...
1151 | )
1152 | else
1153 | hooks[i][hookname] = nil
1154 | end
1155 |
1156 | if a ~= nil and i > -2 and i < 2 then
1157 | return a, b, c, d, e, f
1158 | end
1159 | end
1160 | end
1161 | end
1162 |
1163 | if gm and gm[name] then
1164 | return ExecuteTransaction(
1165 | string.format(HOOK_TXN_FORMAT, "GM", name), ctx, gm[name], gm, ...
1166 | )
1167 | end
1168 | end
1169 |
1170 | local function hookCall(detour, name, ...)
1171 | return ExecuteTransaction(nil, {tags = {hook = name}}, detour.func, name, ...)
1172 | end
1173 |
1174 | local hookTypes = {
1175 | {override = actualHookCall, module = "lua/includes/modules/hook.lua"},
1176 | {override = ulxHookCall, module = "lua/ulib/shared/hook.lua"},
1177 | {override = ulxHookCall, module = "addons/ulib/lua/ulib/shared/hook.lua"},
1178 | }
1179 | ---
1180 | -- Work out how to detour hook.Call
1181 | -- hook.Call is a popular override target, so a bit of custom logic is needed to
1182 | -- succeed against things like ULib
1183 | -- @return Detour object if successful, false otherwise
1184 | local function detourHookCall()
1185 | for _, hook in pairs(hookTypes) do
1186 | local detour = createDetour(hookCall, "hook.Call", hook.module)
1187 | if detour then
1188 | detour.func = hook.override
1189 | return detour
1190 | end
1191 | end
1192 |
1193 | return false
1194 | end
1195 |
1196 | local toDetour = {
1197 | {
1198 | target = "concommand.Run",
1199 | override = concommandRun,
1200 | module = "lua/includes/modules/concommand.lua",
1201 | },
1202 | {
1203 | target = "net.Incoming",
1204 | override = netIncoming,
1205 | module = "lua/includes/extensions/net.lua",
1206 | },
1207 | }
1208 | local ERR_PREDETOURED =
1209 | "Cannot override function %q as it is already overidden! Maybe add it to no_detour?"
1210 | ---
1211 | -- Detour every function that hasn't been disabled with config.no_detour
1212 | -- Raises an error if a function can't be detoured
1213 | local function doDetours()
1214 | local no_detour = {}
1215 | for _, funcname in ipairs(config["no_detour"]) do
1216 | no_detour[funcname] = true
1217 | end
1218 |
1219 | for _, deets in pairs(toDetour) do
1220 | if not no_detour[deets.target] then
1221 | local detour = createDetour(deets.override, deets.target, deets.module)
1222 | if not detour then
1223 | error(string.format(ERR_PREDETOURED, deets.target))
1224 | end
1225 | detour:Detour()
1226 | end
1227 | end
1228 |
1229 | if not no_detour["hook.Call"] then
1230 | local detour = detourHookCall()
1231 | if not detour then
1232 | error(string.format(ERR_PREDETOURED, "hook.Call"))
1233 | end
1234 | detour:Detour()
1235 | end
1236 | end
1237 |
1238 | --
1239 | -- Initial Configuration
1240 | --
1241 | local DSN_FORMAT = "^(https?://)(%w+):?(%w-)@([%w.:]+)/(%w+)$"
1242 | ---
1243 | -- Validates a sentry DSN and stores it in the config
1244 | -- @param dsn The passed string
1245 | local function parseDSN(dsn)
1246 | local scheme, publickey, privatekey, host, project =
1247 | string.match(dsn, DSN_FORMAT)
1248 | if not (scheme and publickey and host and project) then
1249 | error("Malformed DSN!")
1250 | end
1251 | if privatekey == "" then
1252 | privatekey = nil
1253 | end
1254 | config.privatekey = privatekey
1255 | config.publickey = publickey
1256 | config.projectID = project
1257 | config.endpoint = scheme .. host .. "/api/" .. project .. "/store/"
1258 | end
1259 |
1260 | local settables = {"tags", "release", "environment", "server_name", "no_detour"}
1261 | ---
1262 | -- Configures and activates Sentry
1263 | -- @usage sentry.Setup("https://key@sentry.io/1337", {server_name="server 7", release="v23", environment="production"})
1264 | -- @param dsn The DSN sentry gave you when you set up your project
1265 | -- @param[opt] extra Additional config values to store in sentry. Valid keys `tags`, `release`, `environment`, `server_name`, `no_detour`
1266 | function Setup(dsn, extra)
1267 | parseDSN(dsn)
1268 |
1269 | if extra then
1270 | for _, key in pairs(settables) do
1271 | if extra[key] ~= nil then
1272 | config[key] = extra[key]
1273 | end
1274 | end
1275 | end
1276 |
1277 | if not config["server_name"] then
1278 | config["server_name"] = GetHostName()
1279 | end
1280 |
1281 | doDetours()
1282 |
1283 | luaerror.EnableRuntimeDetour(true)
1284 | luaerror.EnableCompiletimeDetour(true)
1285 |
1286 | hook.Add("LuaError", "Sentry Integration", OnLuaError)
1287 |
1288 | -- Once the server has initialised, get all the things with a "version" field
1289 | hook.Add("Initialize", "Sentry Integration", detectModules)
1290 | -- Just in case we're being called in the Initialize hook, also get them now.
1291 | detectModules()
1292 | end
1293 |
--------------------------------------------------------------------------------