What each trigger actually sees
onEdit fires the moment a user finishes editing a cell — pressing Enter, Tab, or clicking away. The event object (e) gives you e.range, e.value, e.oldValue, e.user, and the sheet name. That range is a single-cell Range object, so you can call getRow(), getColumn(), and getSheet() on it without any extra lookups.
onChange fires on a broader class of events: row or column insertions and deletions, paste operations, IMPORTRANGE and other formula refreshes that change the cell graph, and format changes. The critical difference is that e.range does not exist on an onChange event. Trying to read e.range.getRow() inside an onChange handler throws a TypeError at runtime, which is silent unless you check the execution log.
The changeType string tells you what happened: INSERT_ROW, REMOVE_ROW, INSERT_COLUMN, REMOVE_COLUMN, PASTE, FORMAT, or OTHER. That is all the location information you get. If you need to know which row was inserted, you have to infer it from a snapshot you took before, or use a different architecture (like recording row counts on each onEdit and diffing on onChange).
One thing that trips people up: a simple trigger named onEdit installs automatically when you save the script. onChange does not — you must go to Triggers (the clock icon in the editor) and add an installable trigger pointing at your onChange function, tied to the spreadsheet's On change event. Without that step, the function exists but never runs.